From fb0870588356e5d197c9ecf7ecf522346aa1cc67 Mon Sep 17 00:00:00 2001 From: bartool Date: Wed, 20 May 2026 22:38:51 +0200 Subject: [PATCH] feat: Implement initial structure for Directus Mayo API extension - Added main router in src/index.js to register endpoints. - Implemented GET /mayo-api/products to fetch product list with pagination and filters. - Implemented GET /mayo-api/dictionaries to fetch various dictionaries for frontend use. - Created separate files for routes, repositories, serializers, and utilities to maintain clean architecture. - Added utility functions for async handling, pagination, and order search parsing. - Introduced serializers for products and dictionaries to format data for frontend consumption. - Established repository functions for database queries related to products and dictionaries. - Updated package.json to include license information. - Created documentation for the API extension detailing current state and future implementation plans. --- db_schema.dbml | 206 +++++----- .../directus-extension-mayo-api/README.md | 16 + .../directus-extension-mayo-api/mayo-api.http | 56 +++ .../old/index.js.old | 252 ++++++++++++ .../{old_index.js => old/old_index.js.old} | 0 .../directus-extension-mayo-api/package.json | 1 + .../directus-extension-mayo-api/src/index.js | 257 +------------ .../repositories/dictionaries.repository.js | 19 + .../src/repositories/products.repository.js | 250 ++++++++++++ .../src/routes/dictionaries.get.js | 13 + .../src/routes/products.get.js | 12 + .../serializers/dictionaries.serializer.js | 54 +++ .../src/serializers/product.serializer.js | 22 ++ .../src/serializers/timeline.serializer.js | 29 ++ .../src/utils/async-handler.js | 9 + .../src/utils/order-search.js | 16 + .../src/utils/pagination.js | 28 ++ notes/directus-api.md | 358 ++++++++++++++++++ 18 files changed, 1258 insertions(+), 340 deletions(-) create mode 100644 directus/extensions/directus-extension-mayo-api/README.md create mode 100644 directus/extensions/directus-extension-mayo-api/mayo-api.http create mode 100644 directus/extensions/directus-extension-mayo-api/old/index.js.old rename directus/extensions/directus-extension-mayo-api/{old_index.js => old/old_index.js.old} (100%) create mode 100644 directus/extensions/directus-extension-mayo-api/src/repositories/dictionaries.repository.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/repositories/products.repository.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/routes/dictionaries.get.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/routes/products.get.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/serializers/dictionaries.serializer.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/serializers/product.serializer.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/serializers/timeline.serializer.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/utils/async-handler.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/utils/order-search.js create mode 100644 directus/extensions/directus-extension-mayo-api/src/utils/pagination.js create mode 100644 notes/directus-api.md diff --git a/db_schema.dbml b/db_schema.dbml index 08ada79..49b1751 100644 --- a/db_schema.dbml +++ b/db_schema.dbml @@ -1,76 +1,52 @@ // Directus Database Schema for Mayo -// Generated from snapshot(6).json - -Enum mayo_models_neck_construction { - NTB - BOLT_ON - SET_IN -} - -Enum mayo_parts_part_type { - BODY - NECK -} - -Enum mayo_parts_finish { - GLOSS - SATIN - MAT - NITRO -} +// Revised schema proposal +// Updated: 2026-05-20 Table mayo_clients { id integer [primary key] name "character varying" [unique, not null] - country "character varying" [not null] + country "character varying" } -Table mayo_color { +Table mayo_colors { id integer [primary key] name "character varying" [unique, not null] } -Table mayo_lisst_products { +Table mayo_finishes { + id integer [primary key] + code "character varying" [unique, not null] + name "character varying" [not null] +} + +Table mayo_neck_constructions { + id integer [primary key] + code "character varying" [unique, not null] + name "character varying" [not null] +} + +Table mayo_part_types { + id integer [primary key] + code "character varying" [unique, not null] + name "character varying" [not null] +} + +Table mayo_product_models { + id integer [primary key] + name "character varying" [unique, not null] + neck_construction_id integer [not null] +} + +Table mayo_products { id integer [primary key] user_created uuid date_created timestamp user_updated uuid date_updated timestamp - product_id integer [not null] - list_id integer [unique, not null] -} - -Table mayo_lists { - id integer [primary key] - user_created uuid - date_created timestamp - user_updated uuid - date_updated timestamp - name "character varying" [unique, not null] - description "character varying" -} - -Table mayo_models { - id integer [primary key] - name "character varying" [unique, not null] - neck_construction mayo_models_neck_construction [not null] -} - -Table mayo_operations { - id integer [primary key] - name "character varying" [unique, not null] - description text -} - -Table mayo_order_porducts { - id integer [primary key] - user_created uuid - date_created timestamp - user_updated uuid - date_updated timestamp - product_id integer [unique, not null] - order_id integer [not null] - order_index integer [not null] + source_url "character varying" + specification json + specification_last_fetched_at timestamp + product_model_id integer [not null] } Table mayo_orders { @@ -84,49 +60,97 @@ Table mayo_orders { client_id integer [not null] } -Table mayo_part_events { +Table mayo_order_items { id integer [primary key] user_created uuid date_created timestamp user_updated uuid date_updated timestamp - part_id integer [not null] + product_id integer [unique, not null] + order_id integer [not null] + order_index integer [not null] +} + +Table mayo_production_lists { + id integer [primary key] + user_created uuid + date_created timestamp + user_updated uuid + date_updated timestamp + name "character varying" [unique, not null] + description text +} + +Table mayo_product_production_lists { + id integer [primary key] + user_created uuid + date_created timestamp + user_updated uuid + date_updated timestamp + order_item_id integer [not null] + production_list_id integer [not null] +} + +Table mayo_product_parts { + id integer [primary key] + product_id integer [not null] + part_type_id integer [not null] + top_color_id integer + back_color_id integer + top_finish_id integer + back_finish_id integer +} + +Table mayo_production_operations { + id integer [primary key] + code "character varying" [unique, not null] + name "character varying" [unique, not null] + description text +} + +Table mayo_production_events { + id integer [primary key] + user_created uuid + date_created timestamp + user_updated uuid + date_updated timestamp + product_part_id integer [not null] ordinal integer [not null] - date date [not null] + event_date date [not null] operation_id integer note text } -Table mayo_parts { - id integer [primary key] - part_type mayo_parts_part_type [not null] - top_color_id integer [not null] - back_color_id integer [not null] - top_finish mayo_parts_finish [not null] - back_finish mayo_parts_finish [not null] - product_id integer [not null] -} - -Table mayo_products { - id integer [primary key] - user_created uuid - date_created timestamp - user_updated uuid - date_updated timestamp - source_url "character varying" - specification json - model_id integer [not null] -} - // Relationships -Ref: mayo_lisst_products.product_id > mayo_products.id -Ref: mayo_lisst_products.list_id > mayo_lists.id -Ref: mayo_order_porducts.product_id - mayo_products.id -Ref: mayo_order_porducts.order_id > mayo_orders.id +Ref: mayo_product_models.neck_construction_id > mayo_neck_constructions.id +Ref: mayo_products.product_model_id > mayo_product_models.id Ref: mayo_orders.client_id > mayo_clients.id -Ref: mayo_part_events.part_id > mayo_parts.id -Ref: mayo_part_events.operation_id > mayo_operations.id -Ref: mayo_parts.top_color_id > mayo_color.id -Ref: mayo_parts.back_color_id > mayo_color.id -Ref: mayo_parts.product_id > mayo_products.id -Ref: mayo_products.model_id > mayo_models.id +Ref: mayo_order_items.product_id - mayo_products.id +Ref: mayo_order_items.order_id > mayo_orders.id +Ref: mayo_product_production_lists.order_item_id > mayo_order_items.id +Ref: mayo_product_production_lists.production_list_id > mayo_production_lists.id +Ref: mayo_product_parts.product_id > mayo_products.id +Ref: mayo_product_parts.part_type_id > mayo_part_types.id +Ref: mayo_product_parts.top_color_id > mayo_colors.id +Ref: mayo_product_parts.back_color_id > mayo_colors.id +Ref: mayo_product_parts.top_finish_id > mayo_finishes.id +Ref: mayo_product_parts.back_finish_id > mayo_finishes.id +Ref: mayo_production_events.product_part_id > mayo_product_parts.id +Ref: mayo_production_events.operation_id > mayo_production_operations.id + +// Suggested seed values +// +// mayo_neck_constructions: +// - NTB: Neck-through-body +// - BOLT_ON: Bolt-on +// - SET_IN: Set-in +// +// mayo_part_types: +// - BODY: Body +// - NECK: Neck +// +// mayo_finishes: +// - GLOSS: Gloss +// - SATIN: Satin +// - MAT: Mat +// - NITRO: Nitro diff --git a/directus/extensions/directus-extension-mayo-api/README.md b/directus/extensions/directus-extension-mayo-api/README.md new file mode 100644 index 0000000..6b51ca5 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/README.md @@ -0,0 +1,16 @@ +# Directus Mayo API Extension + +Custom Directus endpoint extension for Duck Prod Manager. + +The extension exposes frontend-facing API routes under: + +```txt +/mayo-api +``` + +Current implemented routes: + +```txt +GET /mayo-api/products +GET /mayo-api/dictionaries +``` diff --git a/directus/extensions/directus-extension-mayo-api/mayo-api.http b/directus/extensions/directus-extension-mayo-api/mayo-api.http new file mode 100644 index 0000000..bf4e496 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/mayo-api.http @@ -0,0 +1,56 @@ +@baseUrl = https://bartool.ovh/dpm-api +@token = _gEp4JyW6uBY7xyKVDo2vKUtCLfPGr3D + +### Health check - Directus server +GET {{baseUrl}}/server/health + +### Logowanie +# @name login +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "bartoolina@gmail.com", + "password": "20madafaka" +} + +### +@auth_token = {{login.response.body.data.access_token}} + + +### Mayo API - products +GET {{baseUrl}}/mayo-api/products +Authorization: Bearer {{auth_token}} + +### Mayo API - products with pagination +GET {{baseUrl}}/mayo-api/products?limit=30&offset=0 +Authorization: Bearer {{auth_token}} + +### Mayo API - products by order search +# orderSearch is a JSON string. This example matches order number 0143/2025/1. +GET {{baseUrl}}/mayo-api/products?search=0143/2025/1&orderSearch=[{"raw":"0143/2025/1","type":"fullOrderId","match":"exact","orderNumber":143,"orderNumberPrefix":null,"orderYear":2025,"orderIndex":1}] +Authorization: Bearer {{auth_token}} + +### Mayo API - products by year +GET {{baseUrl}}/mayo-api/products?year=2025 +Authorization: Bearer {{auth_token}} + +### Mayo API - products by model id +GET {{baseUrl}}/mayo-api/products?model=2 +Authorization: Bearer {{auth_token}} + +### Mayo API - products by client id +GET {{baseUrl}}/mayo-api/products?client=1 +Authorization: Bearer {{auth_token}} + +### Mayo API - products by production list id +GET {{baseUrl}}/mayo-api/products?productionList=1 +Authorization: Bearer {{auth_token}} + +### Mayo API - products by finish +GET {{baseUrl}}/mayo-api/products?finish=MAT +Authorization: Bearer {{auth_token}} + +### Mayo API - dictionaries +GET {{baseUrl}}/mayo-api/dictionaries +Authorization: Bearer {{auth_token}} diff --git a/directus/extensions/directus-extension-mayo-api/old/index.js.old b/directus/extensions/directus-extension-mayo-api/old/index.js.old new file mode 100644 index 0000000..f132877 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/old/index.js.old @@ -0,0 +1,252 @@ +export default { + id: "mayo-api", + handler: (router, context) => { + const { services, getSchema, database } = context; + const { ItemsService } = services; + + router.get("/products", async (req, res) => { + const productsService = new ItemsService("mayo_products", { + schema: await getSchema(), + accountability: req.accountability, + }); + const partsService = new ItemsService("mayo_parts", { + schema: await getSchema(), + accountability: req.accountability, + }); + + try { + const products = await productsService.readByQuery({ + fields: [ + "id", + "note", + "model_id.name", + "model_id.strings_count", + "model_id.scale", + ], + limit: -1, + }); + + const productIds = products.map((product) => product.id); + + const parts = await partsService.readByQuery({ + fields: [ + "id", + "product_id", + "part_type", + "top_color_id.name", + "back_color_id.name", + "top_finish", + "back_finish", + ], + filter: { + product_id: { + _in: productIds, + }, + }, + limit: -1, + }); + const partsByProductId = new Map(); + + for (const part of parts) { + const productId = + typeof part.product_id === "object" + ? part.product_id.id + : part.product_id; + + if (!partsByProductId.has(productId)) { + partsByProductId.set(productId, []); + } + + partsByProductId.get(productId).push(part); + } + + const response = products.map((product) => ({ + ...product, + parts: partsByProductId.get(product.id) ?? [], + })); + + res.json(response); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/products-new", async (req, res) => { + try { + const products = await database("mayo_products as product") + .select( + "product.id", + "product.note", + "model.name as model_name", + "model.strings_count as model_strings_count", + "model.scale as model_scale", + ) + .leftJoin("mayo_models as model", "product.model_id", "model.id"); + + const productIds = products.map((product) => product.id); + + const parts = productIds.length + ? await database("mayo_parts as part") + .select( + "part.id", + "part.product_id", + "part.part_type", + "part.top_finish", + "part.back_finish", + "top_color.name as top_color_name", + "back_color.name as back_color_name", + ) + .leftJoin( + "mayo_colors as top_color", + "part.top_color_id", + "top_color.id", + ) + .leftJoin( + "mayo_colors as back_color", + "part.back_color_id", + "back_color.id", + ) + .whereIn("part.product_id", productIds) + : []; + + const partsByProductId = new Map(); + + for (const part of parts) { + if (!partsByProductId.has(part.product_id)) { + partsByProductId.set(part.product_id, []); + } + + partsByProductId.get(part.product_id).push({ + id: part.id, + product_id: part.product_id, + part_type: part.part_type, + top_color_id: { + name: part.top_color_name, + }, + back_color_id: { + name: part.back_color_name, + }, + top_finish: part.top_finish, + back_finish: part.back_finish, + }); + } + + const response = products.map((product) => ({ + id: product.id, + note: product.note, + model_id: { + name: product.model_name, + strings_count: product.model_strings_count, + scale: product.model_scale, + }, + parts: partsByProductId.get(product.id) ?? [], + })); + + res.json(response); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/orders", async (req, res) => { + const itemsService = new ItemsService("mayo_order_products", { + schema: await getSchema(), + accountability: req.accountability, + }); + + try { + const items = await itemsService.readByQuery({ + fields: [ + "id", + "product_order_index", + "order_id.order_number", + "order_id.order_year", + "order_id.client_id.name", + "product_id.id", + "product_id.note", + "product_id.model_id.name", + "product_id.model_id.strings_count", + "product_id.model_id.scale", + ], + limit: -1, + }); + + res.json(items); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/my-products", async (req, res) => { + try { + const products = await database("mayo_products as mp") + .select( + "mp.id", + "mm.name as model", + "mo.order_number", + "mo.order_year", + "mop.product_order_index as order_index", + ) + .leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id") + .leftJoin("mayo_orders as mo", "mo.id", "mop.order_id") + .leftJoin("mayo_models as mm", "mm.id", "mp.model_id"); + + const productIds = products.map((product) => product.id); + + const parts = productIds.length + ? await database("mayo_parts as part") + .select("part.product_id", "part.part_type") + .whereIn("part.product_id", productIds) + : []; + + const partTypesByProductId = new Map(); + + for (const part of parts) { + if (!partTypesByProductId.has(part.product_id)) { + partTypesByProductId.set(part.product_id, []); + } + + if (part.part_type) { + partTypesByProductId.get(part.product_id).push(part.part_type); + } + } + + const response = products.map((product) => ({ + ...product, + part_types: partTypesByProductId.get(product.id) ?? [], + })); + + res.json(response); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/my-products2", async (req, res) => { + const productsService = new ItemsService("mayo_products", { + schema: await getSchema(), + accountability: req.accountability, + }); + const partsService = new ItemsService("mayo_parts", { + schema: await getSchema(), + accountability: req.accountability, + }); + + try { + const products = await productsService.readByQuery({ + fields: [ + "id", + "note", + "model_id.name", + "model_id.strings_count", + "model_id.scale", + ], + limit: -1, + }); + res.json(products); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + }, +}; diff --git a/directus/extensions/directus-extension-mayo-api/old_index.js b/directus/extensions/directus-extension-mayo-api/old/old_index.js.old similarity index 100% rename from directus/extensions/directus-extension-mayo-api/old_index.js rename to directus/extensions/directus-extension-mayo-api/old/old_index.js.old diff --git a/directus/extensions/directus-extension-mayo-api/package.json b/directus/extensions/directus-extension-mayo-api/package.json index f2fb34c..ab1fac9 100644 --- a/directus/extensions/directus-extension-mayo-api/package.json +++ b/directus/extensions/directus-extension-mayo-api/package.json @@ -3,6 +3,7 @@ "description": "Please enter a description for your extension", "icon": "extension", "version": "1.0.1", + "license": "UNLICENSED", "keywords": [ "directus", "directus-extension", diff --git a/directus/extensions/directus-extension-mayo-api/src/index.js b/directus/extensions/directus-extension-mayo-api/src/index.js index f132877..5ab9478 100644 --- a/directus/extensions/directus-extension-mayo-api/src/index.js +++ b/directus/extensions/directus-extension-mayo-api/src/index.js @@ -1,252 +1,11 @@ +import { registerGetDictionaries } from './routes/dictionaries.get.js' +import { registerGetProducts } from './routes/products.get.js' + export default { - id: "mayo-api", + id: 'mayo-api', + handler: (router, context) => { - const { services, getSchema, database } = context; - const { ItemsService } = services; - - router.get("/products", async (req, res) => { - const productsService = new ItemsService("mayo_products", { - schema: await getSchema(), - accountability: req.accountability, - }); - const partsService = new ItemsService("mayo_parts", { - schema: await getSchema(), - accountability: req.accountability, - }); - - try { - const products = await productsService.readByQuery({ - fields: [ - "id", - "note", - "model_id.name", - "model_id.strings_count", - "model_id.scale", - ], - limit: -1, - }); - - const productIds = products.map((product) => product.id); - - const parts = await partsService.readByQuery({ - fields: [ - "id", - "product_id", - "part_type", - "top_color_id.name", - "back_color_id.name", - "top_finish", - "back_finish", - ], - filter: { - product_id: { - _in: productIds, - }, - }, - limit: -1, - }); - const partsByProductId = new Map(); - - for (const part of parts) { - const productId = - typeof part.product_id === "object" - ? part.product_id.id - : part.product_id; - - if (!partsByProductId.has(productId)) { - partsByProductId.set(productId, []); - } - - partsByProductId.get(productId).push(part); - } - - const response = products.map((product) => ({ - ...product, - parts: partsByProductId.get(product.id) ?? [], - })); - - res.json(response); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/products-new", async (req, res) => { - try { - const products = await database("mayo_products as product") - .select( - "product.id", - "product.note", - "model.name as model_name", - "model.strings_count as model_strings_count", - "model.scale as model_scale", - ) - .leftJoin("mayo_models as model", "product.model_id", "model.id"); - - const productIds = products.map((product) => product.id); - - const parts = productIds.length - ? await database("mayo_parts as part") - .select( - "part.id", - "part.product_id", - "part.part_type", - "part.top_finish", - "part.back_finish", - "top_color.name as top_color_name", - "back_color.name as back_color_name", - ) - .leftJoin( - "mayo_colors as top_color", - "part.top_color_id", - "top_color.id", - ) - .leftJoin( - "mayo_colors as back_color", - "part.back_color_id", - "back_color.id", - ) - .whereIn("part.product_id", productIds) - : []; - - const partsByProductId = new Map(); - - for (const part of parts) { - if (!partsByProductId.has(part.product_id)) { - partsByProductId.set(part.product_id, []); - } - - partsByProductId.get(part.product_id).push({ - id: part.id, - product_id: part.product_id, - part_type: part.part_type, - top_color_id: { - name: part.top_color_name, - }, - back_color_id: { - name: part.back_color_name, - }, - top_finish: part.top_finish, - back_finish: part.back_finish, - }); - } - - const response = products.map((product) => ({ - id: product.id, - note: product.note, - model_id: { - name: product.model_name, - strings_count: product.model_strings_count, - scale: product.model_scale, - }, - parts: partsByProductId.get(product.id) ?? [], - })); - - res.json(response); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/orders", async (req, res) => { - const itemsService = new ItemsService("mayo_order_products", { - schema: await getSchema(), - accountability: req.accountability, - }); - - try { - const items = await itemsService.readByQuery({ - fields: [ - "id", - "product_order_index", - "order_id.order_number", - "order_id.order_year", - "order_id.client_id.name", - "product_id.id", - "product_id.note", - "product_id.model_id.name", - "product_id.model_id.strings_count", - "product_id.model_id.scale", - ], - limit: -1, - }); - - res.json(items); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/my-products", async (req, res) => { - try { - const products = await database("mayo_products as mp") - .select( - "mp.id", - "mm.name as model", - "mo.order_number", - "mo.order_year", - "mop.product_order_index as order_index", - ) - .leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id") - .leftJoin("mayo_orders as mo", "mo.id", "mop.order_id") - .leftJoin("mayo_models as mm", "mm.id", "mp.model_id"); - - const productIds = products.map((product) => product.id); - - const parts = productIds.length - ? await database("mayo_parts as part") - .select("part.product_id", "part.part_type") - .whereIn("part.product_id", productIds) - : []; - - const partTypesByProductId = new Map(); - - for (const part of parts) { - if (!partTypesByProductId.has(part.product_id)) { - partTypesByProductId.set(part.product_id, []); - } - - if (part.part_type) { - partTypesByProductId.get(part.product_id).push(part.part_type); - } - } - - const response = products.map((product) => ({ - ...product, - part_types: partTypesByProductId.get(product.id) ?? [], - })); - - res.json(response); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - router.get("/my-products2", async (req, res) => { - const productsService = new ItemsService("mayo_products", { - schema: await getSchema(), - accountability: req.accountability, - }); - const partsService = new ItemsService("mayo_parts", { - schema: await getSchema(), - accountability: req.accountability, - }); - - try { - const products = await productsService.readByQuery({ - fields: [ - "id", - "note", - "model_id.name", - "model_id.strings_count", - "model_id.scale", - ], - limit: -1, - }); - res.json(products); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); + registerGetProducts(router, context) + registerGetDictionaries(router, context) }, -}; +} diff --git a/directus/extensions/directus-extension-mayo-api/src/repositories/dictionaries.repository.js b/directus/extensions/directus-extension-mayo-api/src/repositories/dictionaries.repository.js new file mode 100644 index 0000000..54e2b2e --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/repositories/dictionaries.repository.js @@ -0,0 +1,19 @@ +export async function getDictionaries({ database }) { + const [models, clients, productionLists, operations, colors, finishes] = await Promise.all([ + database('mayo_product_models').select('id', 'name').orderBy('name', 'asc'), + database('mayo_clients').select('id', 'name', 'country').orderBy('name', 'asc'), + database('mayo_production_lists').select('id', 'name').orderBy('name', 'asc'), + database('mayo_production_operations').select('id', 'code', 'name').orderBy('name', 'asc'), + database('mayo_colors').select('id', 'name').orderBy('name', 'asc'), + database('mayo_finishes').select('id', 'code', 'name').orderBy('name', 'asc'), + ]) + + return { + models, + clients, + productionLists, + operations, + colors, + finishes, + } +} diff --git a/directus/extensions/directus-extension-mayo-api/src/repositories/products.repository.js b/directus/extensions/directus-extension-mayo-api/src/repositories/products.repository.js new file mode 100644 index 0000000..9456fe2 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/repositories/products.repository.js @@ -0,0 +1,250 @@ +import { serializeProduct } from '../serializers/product.serializer.js' +import { createTimelinePreview } from '../serializers/timeline.serializer.js' +import { parseOrderSearch } from '../utils/order-search.js' +import { createPageInfo, parsePagination } from '../utils/pagination.js' + +export async function getProducts({ database }, query = {}) { + const { limit, offset } = parsePagination(query) + const orderSearch = parseOrderSearch(query.orderSearch) + + const baseQuery = createProductsBaseQuery(database, query, orderSearch) + + const [{ total }] = await baseQuery + .clone() + .clearSelect() + .clearOrder() + .countDistinct({ total: 'order_item.id' }) + + const rows = await baseQuery + .clone() + .select( + 'order_item.id as order_item_id', + 'order_item.order_index', + 'product.id as product_id', + 'ord.order_number', + 'ord.order_year', + 'model.name as model_name', + 'client.name as client_name', + ) + .orderBy('ord.order_year', 'desc') + .orderBy('ord.order_number', 'desc') + .orderBy('order_item.order_index', 'asc') + .limit(limit) + .offset(offset) + + const productIds = rows.map((row) => row.product_id) + const orderItemIds = rows.map((row) => row.order_item_id) + const [productionListsByOrderItemId, finishesByProductId, eventsByProductId] = await Promise.all([ + readProductionListsByOrderItemId(database, orderItemIds), + readFinishesByProductId(database, productIds), + readEventsByProductId(database, productIds), + ]) + + const items = rows.map((row) => + serializeProduct(row, { + finish: serializeFinish(finishesByProductId.get(row.product_id)), + productionLists: productionListsByOrderItemId.get(row.order_item_id) ?? [], + timelinePreview: createTimelinePreview(eventsByProductId.get(row.product_id) ?? []), + }), + ) + + return { + items, + pageInfo: createPageInfo({ + limit, + offset, + total: Number(total), + }), + } +} + +function createProductsBaseQuery(database, query, orderSearch) { + const productsQuery = database('mayo_order_items as order_item') + .join('mayo_products as product', 'order_item.product_id', 'product.id') + .join('mayo_orders as ord', 'order_item.order_id', 'ord.id') + .leftJoin('mayo_product_models as model', 'product.product_model_id', 'model.id') + .leftJoin('mayo_clients as client', 'ord.client_id', 'client.id') + + applyFilters(productsQuery, query, orderSearch) + + return productsQuery +} + +function applyFilters(productsQuery, query, orderSearch) { + if (query.model) { + productsQuery.where('product.product_model_id', query.model) + } + + if (query.client) { + productsQuery.where('ord.client_id', query.client) + } + + if (query.year) { + productsQuery.where('ord.order_year', Number(query.year)) + } + + if (query.productionList) { + productsQuery.whereExists(function existsProductionList() { + this.select(1) + .from('mayo_product_production_lists as product_list') + .whereRaw('product_list.order_item_id = order_item.id') + .where('product_list.production_list_id', query.productionList) + }) + } + + if (query.finish) { + productsQuery.whereExists(function existsFinish() { + this.select(1) + .from('mayo_product_parts as part') + .leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id') + .leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id') + .whereRaw('part.product_id = product.id') + .where((finishQuery) => { + finishQuery.where('top_finish.code', query.finish).orWhere('back_finish.code', query.finish) + }) + }) + } + + applyOrderSearchFilter(productsQuery, query.search, orderSearch) +} + +function applyOrderSearchFilter(productsQuery, search, orderSearch) { + if (!String(search ?? '').trim()) { + return + } + + if (!orderSearch.length) { + productsQuery.whereRaw('1 = 0') + return + } + + productsQuery.where((conditionsQuery) => { + for (const condition of orderSearch) { + conditionsQuery.orWhere((conditionQuery) => { + applyOrderSearchCondition(conditionQuery, condition) + }) + } + }) +} + +function applyOrderSearchCondition(conditionQuery, condition) { + if (condition.type === 'orderNumberPrefix' && condition.orderNumberPrefix) { + conditionQuery.where('ord.order_number', 'like', `${condition.orderNumberPrefix}%`) + return + } + + if (condition.orderNumber !== null && condition.orderNumber !== undefined) { + conditionQuery.whereRaw('CAST("ord"."order_number" AS INTEGER) = ?', [condition.orderNumber]) + } + + if (condition.orderYear !== null && condition.orderYear !== undefined) { + conditionQuery.where('ord.order_year', condition.orderYear) + } + + if (condition.orderIndex !== null && condition.orderIndex !== undefined) { + conditionQuery.where('order_item.order_index', condition.orderIndex) + } +} + +async function readProductionListsByOrderItemId(database, orderItemIds) { + const result = new Map() + + if (!orderItemIds.length) { + return result + } + + const rows = await database('mayo_product_production_lists as product_list') + .select('product_list.order_item_id', 'production_list.name') + .join( + 'mayo_production_lists as production_list', + 'product_list.production_list_id', + 'production_list.id', + ) + .whereIn('product_list.order_item_id', orderItemIds) + .orderBy('production_list.name', 'asc') + + for (const row of rows) { + if (!result.has(row.order_item_id)) { + result.set(row.order_item_id, []) + } + + result.get(row.order_item_id).push(row.name) + } + + return result +} + +async function readFinishesByProductId(database, productIds) { + const result = new Map() + + if (!productIds.length) { + return result + } + + const rows = await database('mayo_product_parts as part') + .select( + 'part.product_id', + 'top_finish.code as top_finish_code', + 'back_finish.code as back_finish_code', + ) + .leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id') + .leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id') + .whereIn('part.product_id', productIds) + + for (const row of rows) { + if (!result.has(row.product_id)) { + result.set(row.product_id, new Set()) + } + + const finishes = result.get(row.product_id) + if (row.top_finish_code) finishes.add(row.top_finish_code) + if (row.back_finish_code) finishes.add(row.back_finish_code) + } + + return result +} + +async function readEventsByProductId(database, productIds) { + const result = new Map() + + if (!productIds.length) { + return result + } + + const rows = await database('mayo_production_events as event') + .select( + 'event.id', + 'event.event_date as date', + 'part.product_id', + 'part_type.code as part_type', + 'operation.id as operation_id', + 'operation.code as operation_code', + 'operation.name as operation_name', + 'operation.description as operation_description', + ) + .join('mayo_product_parts as part', 'event.product_part_id', 'part.id') + .join('mayo_part_types as part_type', 'part.part_type_id', 'part_type.id') + .leftJoin('mayo_production_operations as operation', 'event.operation_id', 'operation.id') + .whereIn('part.product_id', productIds) + .whereNotNull('event.operation_id') + .orderBy('event.event_date', 'asc') + .orderBy('event.ordinal', 'asc') + + for (const row of rows) { + if (!result.has(row.product_id)) { + result.set(row.product_id, []) + } + + result.get(row.product_id).push(row) + } + + return result +} + +function serializeFinish(finishes) { + if (!finishes?.size) { + return null + } + + return [...finishes].sort().join('+') +} diff --git a/directus/extensions/directus-extension-mayo-api/src/routes/dictionaries.get.js b/directus/extensions/directus-extension-mayo-api/src/routes/dictionaries.get.js new file mode 100644 index 0000000..e2515ca --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/routes/dictionaries.get.js @@ -0,0 +1,13 @@ +import { getDictionaries } from '../repositories/dictionaries.repository.js' +import { serializeDictionaries } from '../serializers/dictionaries.serializer.js' +import { asyncHandler } from '../utils/async-handler.js' + +export function registerGetDictionaries(router, context) { + router.get( + '/dictionaries', + asyncHandler(async (req, res) => { + const dictionaries = await getDictionaries(context) + res.json(serializeDictionaries(dictionaries)) + }), + ) +} diff --git a/directus/extensions/directus-extension-mayo-api/src/routes/products.get.js b/directus/extensions/directus-extension-mayo-api/src/routes/products.get.js new file mode 100644 index 0000000..0fb5cb7 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/routes/products.get.js @@ -0,0 +1,12 @@ +import { getProducts } from '../repositories/products.repository.js' +import { asyncHandler } from '../utils/async-handler.js' + +export function registerGetProducts(router, context) { + router.get( + '/products', + asyncHandler(async (req, res) => { + const result = await getProducts(context, req.query) + res.json(result) + }), + ) +} diff --git a/directus/extensions/directus-extension-mayo-api/src/serializers/dictionaries.serializer.js b/directus/extensions/directus-extension-mayo-api/src/serializers/dictionaries.serializer.js new file mode 100644 index 0000000..60d5b7e --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/serializers/dictionaries.serializer.js @@ -0,0 +1,54 @@ +export function serializeDictionaries(dictionaries) { + return { + models: dictionaries.models.map(serializeModel), + clients: dictionaries.clients.map(serializeClient), + finishes: dictionaries.finishes.map(serializeFinish), + productionLists: dictionaries.productionLists.map(serializeProductionList), + operations: dictionaries.operations.map(serializeOperation), + colors: dictionaries.colors.map(serializeColor), + } +} + +function serializeModel(model) { + return { + id: model.id, + name: model.name, + } +} + +function serializeClient(client) { + return { + id: client.id, + name: client.name, + country: client.country, + } +} + +function serializeFinish(finish) { + return { + value: finish.code, + label: finish.name, + } +} + +function serializeProductionList(productionList) { + return { + id: productionList.id, + name: productionList.name, + } +} + +function serializeOperation(operation) { + return { + id: operation.id, + code: operation.code, + name: operation.name, + } +} + +function serializeColor(color) { + return { + id: color.id, + name: color.name, + } +} diff --git a/directus/extensions/directus-extension-mayo-api/src/serializers/product.serializer.js b/directus/extensions/directus-extension-mayo-api/src/serializers/product.serializer.js new file mode 100644 index 0000000..edd5b36 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/serializers/product.serializer.js @@ -0,0 +1,22 @@ +export function serializeProduct(row, { finish, productionLists, timelinePreview }) { + const orderNumber = formatOrderNumber(row.order_number) + const orderIndex = Number(row.order_index) + const orderYear = Number(row.order_year) + + return { + id: row.product_id, + orderId: `${orderNumber}/${orderYear}/${orderIndex}`, + orderNumber, + orderYear, + orderIndex, + model: row.model_name ?? null, + client: row.client_name ?? null, + finish, + productionLists, + timelinePreview, + } +} + +function formatOrderNumber(value) { + return String(value ?? '').padStart(4, '0') +} diff --git a/directus/extensions/directus-extension-mayo-api/src/serializers/timeline.serializer.js b/directus/extensions/directus-extension-mayo-api/src/serializers/timeline.serializer.js new file mode 100644 index 0000000..215c587 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/serializers/timeline.serializer.js @@ -0,0 +1,29 @@ +export function createTimelinePreview(events) { + return { + body: createPartTimelinePreview(events, 'BODY'), + neck: createPartTimelinePreview(events, 'NECK'), + } +} + +function createPartTimelinePreview(events, partType) { + return events + .filter((event) => event.part_type === partType) + .map((event) => ({ + id: event.id, + code: createOperationCode(event), + label: event.operation_name, + date: event.date, + status: 'done', + })) +} + +function createOperationCode(event) { + if (event.operation_code) { + return event.operation_code + } + + const source = event.operation_description || event.operation_name || '' + const firstWord = source.trim().split(/\s+/)[0] ?? '' + + return firstWord.slice(0, 3).toUpperCase() +} diff --git a/directus/extensions/directus-extension-mayo-api/src/utils/async-handler.js b/directus/extensions/directus-extension-mayo-api/src/utils/async-handler.js new file mode 100644 index 0000000..e5d3762 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/utils/async-handler.js @@ -0,0 +1,9 @@ +export function asyncHandler(handler) { + return async (req, res, next) => { + try { + await handler(req, res, next) + } catch (error) { + next(error) + } + } +} diff --git a/directus/extensions/directus-extension-mayo-api/src/utils/order-search.js b/directus/extensions/directus-extension-mayo-api/src/utils/order-search.js new file mode 100644 index 0000000..3763cb7 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/utils/order-search.js @@ -0,0 +1,16 @@ +export function parseOrderSearch(value) { + if (!value) { + return [] + } + + if (Array.isArray(value)) { + return value + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} diff --git a/directus/extensions/directus-extension-mayo-api/src/utils/pagination.js b/directus/extensions/directus-extension-mayo-api/src/utils/pagination.js new file mode 100644 index 0000000..6ab78e5 --- /dev/null +++ b/directus/extensions/directus-extension-mayo-api/src/utils/pagination.js @@ -0,0 +1,28 @@ +const DEFAULT_LIMIT = 30 +const MAX_LIMIT = 100 + +export function parsePagination(query = {}) { + const limit = clampNumber(query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT) + const offset = clampNumber(query.offset, 0, 0, Number.MAX_SAFE_INTEGER) + + return { limit, offset } +} + +export function createPageInfo({ limit, offset, total }) { + return { + limit, + offset, + hasMore: offset + limit < total, + total, + } +} + +function clampNumber(value, fallback, min, max) { + const parsed = Number.parseInt(value, 10) + + if (!Number.isFinite(parsed)) { + return fallback + } + + return Math.min(Math.max(parsed, min), max) +} diff --git a/notes/directus-api.md b/notes/directus-api.md new file mode 100644 index 0000000..a6da31d --- /dev/null +++ b/notes/directus-api.md @@ -0,0 +1,358 @@ +# Directus Mayo API Extension + +Data aktualizacji: 2026-05-20 + +## Cel + +Ten dokument opisuje aktualny stan budowy custom API w Directus dla frontendu Duck Prod Manager. + +Extension znajduje sie w: + +```txt +directus/extensions/directus-extension-mayo-api +``` + +Endpoint Directus extension ma miec identyfikator: + +```txt +mayo-api +``` + +Dlatego publiczne sciezki beda dostepne pod prefiksem: + +```txt +/mayo-api +``` + +## Aktualny stan + +Katalog extension jest przygotowany i zawiera scaffold Directus endpoint extension. + +Istniejace pliki: + +```txt +directus/extensions/directus-extension-mayo-api/README.md +directus/extensions/directus-extension-mayo-api/package.json +directus/extensions/directus-extension-mayo-api/src/index.js +directus/extensions/directus-extension-mayo-api/old/index.js.old +directus/extensions/directus-extension-mayo-api/old/old_index.js.old +``` + +`src/index.js` rejestruje obecnie pierwsze dwa endpointy: + +- `GET /mayo-api/products` +- `GET /mayo-api/dictionaries` + +Stare pliki w `old/` sa tylko materialem referencyjnym. Nie traktujemy ich jako aktywnej implementacji. + +## Zalozona architektura + +Nie umieszczamy wszystkich endpointow w jednym pliku. + +`src/index.js` ma pelnic role glownego routera: + +- eksportuje Directus endpoint extension, +- ustawia `id: 'mayo-api'`, +- importuje pliki endpointow, +- rejestruje endpointy, +- nie zawiera zapytan SQL ani logiki biznesowej. + +Kazdy endpoint ma miec osobny plik w katalogu `routes/`. + +Logika wspolna powinna byc rozbita na: + +- `repositories/` - zapytania do bazy Directus, +- `serializers/` - mapowanie danych z DB na format frontendu, +- `utils/` - drobne helpery techniczne. + +## Proponowana struktura plikow + +```txt +directus/extensions/directus-extension-mayo-api/src/ + index.js + + routes/ + products.get.js + dictionaries.get.js + product-timeline.get.js # do zrobienia + product-timeline-event.post.js # do zrobienia + product-specification.get.js # do zrobienia + product-specification-refresh.post.js # do zrobienia + + repositories/ + products.repository.js + dictionaries.repository.js + timeline.repository.js # do zrobienia + specification.repository.js # do zrobienia + + serializers/ + product.serializer.js + timeline.serializer.js + specification.serializer.js # do zrobienia + dictionaries.serializer.js + + utils/ + async-handler.js + http-error.js # do zrobienia, jesli bedzie potrzebny + pagination.js + order-search.js +``` + +## Endpointy wymagane przez frontend + +### 1. Lista produktow + +```http +GET /mayo-api/products +``` + +Status: zaimplementowany w pierwszym etapie. + +Parametry query: + +```js +{ + limit, + offset, + search, + orderSearch, + model, + client, + finish, + productionList, + year, +} +``` + +`orderSearch` przychodzi z frontendu jako JSON string. + +Oczekiwana odpowiedz: + +```js +{ + items: [], + pageInfo: { + limit, + offset, + hasMore, + total, + }, +} +``` + +Element `items` powinien miec ksztalt produktu uzywany przez frontend: + +```js +{ + id, + orderId, + orderNumber, + orderYear, + orderIndex, + model, + client, + finish, + productionLists, + timelinePreview: { + body: [], + neck: [], + }, +} +``` + +### 2. Slowniki + +```http +GET /mayo-api/dictionaries +``` + +Status: zaimplementowany w pierwszym etapie. + +Oczekiwana odpowiedz: + +```js +{ + models: [], + clients: [], + finishes: [], + productionLists: [], + operations: [], + colors: [], +} +``` + +### 3. Timeline produktu + +```http +GET /mayo-api/products/:id/timeline +``` + +Status: do zrobienia. + +Oczekiwana odpowiedz: + +```js +{ + productId, + events: [], + timelinePreview: { + body: [], + neck: [], + }, +} +``` + +### 4. Dodanie eventu timeline + +```http +POST /mayo-api/products/:id/timeline/events +``` + +Status: do zrobienia. + +Oczekiwana odpowiedz: + +```js +{ + event: {}, + timelinePreview: { + body: [], + neck: [], + }, +} +``` + +### 5. Specyfikacja produktu + +```http +GET /mayo-api/products/:id/specification +``` + +Status: do zrobienia. + +Preferowana odpowiedz: + +```js +{ + productId, + orderId, + sourceUrl, + lastFetchedAt, + sections: [], + diff: [], +} +``` + +Frontend akceptuje tez ksztalt: + +```js +{ + productId, + orderId, + sourceUrl, + lastFetchedAt, + specification: { + sections: [], + }, + diff: [], +} +``` + +### 6. Odswiezenie specyfikacji produktu + +```http +POST /mayo-api/products/:id/specification/refresh +``` + +Status: do zrobienia. + +Odpowiedz powinna miec taki sam ksztalt jak `GET /specification`, ale z aktualnym `lastFetchedAt` i ewentualnym `diff`. + +## Kolejnosc implementacji + +Rekomendowana kolejnosc: + +1. `GET /mayo-api/products` +2. `GET /mayo-api/dictionaries` +3. `GET /mayo-api/products/:id/timeline` +4. `POST /mayo-api/products/:id/timeline/events` +5. `GET /mayo-api/products/:id/specification` +6. `POST /mayo-api/products/:id/specification/refresh` + +Pierwsze dwa endpointy odblokowuja glowny ekran frontendu. + +## Co zostalo zrobione + +- Przeanalizowano `notes/api_services.md`. +- Przeanalizowano frontendowe implementacje HTTP w `frontend/src/services/*HttpApi.js`. +- Przeanalizowano store Pinia, zeby ustalic realne parametry i ksztalt danych. +- Ustalono liste 6 endpointow wymaganych przez obecny frontend. +- Ustalono, ze `index.js` bedzie tylko routerem/rejestratorem endpointow. +- Ustalono docelowa hierarchie plikow extension. +- Utworzono ten dokument jako aktualny opis stanu prac nad Directus extension. +- Utworzono katalogi `routes/`, `repositories/`, `serializers/`, `utils/`. +- Wypelniono `src/index.js` rejestracja pierwszych endpointow. +- Zaimplementowano `GET /mayo-api/products`. +- Zaimplementowano `GET /mayo-api/dictionaries`. +- Zweryfikowano nazwy kolekcji na podstawie `snapshot(6).json` i `db_schema.dbml`. +- Uruchomiono `npm run build` w katalogu extension. Build zakonczyl sie poprawnie. +- Dodano `README.md` dla extension. +- Dodano `license: "UNLICENSED"` w `package.json`, zeby extension bylo oznaczone jako pakiet wewnetrzny. +- Uruchomiono `npm run validate` w katalogu extension. Walidacja zakonczyla sie poprawnie. +- Dodano `mayo-api.http` z requestami REST Client do manualnego testowania endpointow. +- Przebudowano `db_schema.dbml` pod nowa baze Directus: poprawione nazwy, tabele slownikowe zamiast enumow. +- Dostosowano aktywne repository extension do nowego schematu. +- Uwzgledniono relacje `mayo_product_production_lists.order_item_id -> mayo_order_items.id`. + +## Decyzje implementacyjne + +- Aktywna implementacja ignoruje katalog `directus/extensions/directus-extension-mayo-api/old`. +- Pierwsza implementacja byla oparta o stary schemat Directus, ktory zawieral literowki: + - `mayo_order_porducts` + - `mayo_lisst_products` + - `mayo_color` +- `db_schema.dbml` zostal przebudowany jako propozycja nowego schematu: + - enumy zostaly zamienione na tabele slownikowe, + - literowki zostaly poprawione, + - nazwy kolekcji zostaly doprecyzowane pod Directus. +- Nowa baza Directus zostala utworzona zgodnie z `db_schema.dbml`, z jedna celowa zmiana: + - `mayo_product_production_lists.order_item_id` wskazuje na `mayo_order_items.id`. +- Extension zostal dostosowany do nowych nazw kolekcji i relacji `order_item_id`. +- `GET /products` buduje `timelinePreview` z `mayo_production_events`, `mayo_product_parts`, `mayo_part_types` i `mayo_production_operations`. +- `GET /products` filtruje po `model`, `client`, `year`, `productionList`, `finish` i `orderSearch`. +- `GET /dictionaries` pobiera finish z tabeli slownikowej `mayo_finishes`. +- Kod operacji pochodzi z `mayo_production_operations.code`. +- Uruchomiono `npm run build` po dostosowaniu do nowego schematu. Build zakonczyl sie poprawnie. +- Uruchomiono `npm run validate` po dostosowaniu do nowego schematu. Walidacja zakonczyla sie poprawnie. + +## Proponowane nowe nazwy kolekcji + +Stare nazwy i ich proponowane odpowiedniki: + +```txt +mayo_color -> mayo_colors +mayo_lisst_products -> mayo_product_production_lists +mayo_lists -> mayo_production_lists +mayo_models -> mayo_product_models +mayo_operations -> mayo_production_operations +mayo_order_porducts -> mayo_order_items +mayo_part_events -> mayo_production_events +mayo_parts -> mayo_product_parts +``` + +Enumy zostaly zastapione tabelami: + +```txt +mayo_models_neck_construction -> mayo_neck_constructions +mayo_parts_part_type -> mayo_part_types +mayo_parts_finish -> mayo_finishes +``` + +## Do zrobienia + +- Zaimplementowac `GET /mayo-api/products/:id/timeline`. +- Zaimplementowac `POST /mayo-api/products/:id/timeline/events`. +- Zaimplementowac `GET /mayo-api/products/:id/specification`. +- Zaimplementowac `POST /mayo-api/products/:id/specification/refresh`. +- Sprawdzic na realnych danych, czy `finish` na karcie produktu ma byc pojedynczym enumem, czy agregatem z `top_finish` i `back_finish`. +- Przetestowac frontend z `VITE_USE_MOCK_API=false`.