From ec2531cacbb2cf47092663a6440656fb3cafbeca Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:44:22 +0300 Subject: [PATCH 1/8] Initial sql for MariaDB --- docker/init.sql | 105 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docker/init.sql diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..8dc309d --- /dev/null +++ b/docker/init.sql @@ -0,0 +1,105 @@ +/*M!999999\- enable the sandbox mode */ +-- MariaDB dump 10.19-11.7.2-MariaDB, for Win64 (AMD64) +-- +-- Host: localhost Database: delivery +-- ------------------------------------------------------ +-- Server version 12.1.2-MariaDB + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */; + +-- +-- Table structure for table `order_items` +-- + +DROP TABLE IF EXISTS `order_items`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `order_items` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `order_id` varchar(256) DEFAULT NULL, + `product_id` varchar(256) DEFAULT NULL, + `quantity` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `order_items_orders_FK` (`order_id`), + KEY `order_items_products_FK` (`product_id`), + CONSTRAINT `order_items_orders_FK` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `order_items_products_FK` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `order_items` +-- + +LOCK TABLES `order_items` WRITE; +/*!40000 ALTER TABLE `order_items` DISABLE KEYS */; +/*!40000 ALTER TABLE `order_items` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `orders` +-- + +DROP TABLE IF EXISTS `orders`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `orders` ( + `id` varchar(256) NOT NULL, + `customer_name` varchar(256) DEFAULT NULL, + `order_date` date DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `products` +-- + +DROP TABLE IF EXISTS `products`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `products` ( + `id` varchar(256) NOT NULL, + `name` varchar(256) NOT NULL, + `quantity` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `products` +-- + +LOCK TABLES `products` WRITE; +/*!40000 ALTER TABLE `products` DISABLE KEYS */; +INSERT INTO `products` VALUES +('QH-1','Отвёртки',30), +('QH-3','Гайки',13), +('QW-1','Молоток',32), +('QW-2','Гвозди',65); +/*!40000 ALTER TABLE `products` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Dumping routines for database 'delivery' +-- +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */; + +-- Dump completed on 2026-01-03 18:57:33 -- 2.49.1 From c6c3cf22274ec157f563b7df67c928de48535901 Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:44:35 +0300 Subject: [PATCH 2/8] env file for Docker --- server/docker.env | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 server/docker.env diff --git a/server/docker.env b/server/docker.env new file mode 100644 index 0000000..d87aeee --- /dev/null +++ b/server/docker.env @@ -0,0 +1,5 @@ +HOST="db" +PORT=3306 +DATABASE="delivery" +USER="app" +PASSWORD="app" \ No newline at end of file -- 2.49.1 From 8c9ea539642e47c48b91d0bbd1663d5102d971dd Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:46:45 +0300 Subject: [PATCH 3/8] Nginx --- Dockerfile.client | 4 ++++ docker/nginx.conf | 19 +++++++++++++++++++ server/server.js | 3 ++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.client create mode 100644 docker/nginx.conf diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..635f794 --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY public /usr/share/nginx/html diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..143b2bd --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://server:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/server/server.js b/server/server.js index a429520..bdf7ee8 100644 --- a/server/server.js +++ b/server/server.js @@ -4,8 +4,9 @@ import { randomUUID } from "crypto"; import DBAdapter, { getCurrentDate, setCurrentDate } from "./db/database.js"; import { DB_INTERNAL_ERROR, DB_USER_ERROR } from "./db/database.js"; +// Switch to .env for test in local machine dotenv.config({ - path: './server/.env' + path: './server/docker.env' }); const { -- 2.49.1 From 151c98e994ff93fba6583678b59ac26d02896565 Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:47:01 +0300 Subject: [PATCH 4/8] Server --- .dockerignore | 6 +++++ Dockerfile.server | 13 ++++++++++ docker-compose.yml | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile.server create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b1239e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +.gitignore +.gitattributes +README.md +DELIVER_MAN.slnx diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..b2b8829 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY server ./server + +ENV NODE_ENV=production +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9e06a42 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: delivery + MARIADB_USER: app + MARIADB_PASSWORD: app + MARIADB_ROOT_PASSWORD: root + volumes: + - db_data:/var/lib/mysql + - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - backend + healthcheck: + test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + + server: + build: + context: . + dockerfile: Dockerfile.server + environment: + HOST: db + PORT: 3306 + DATABASE: delivery + USER: app + PASSWORD: app + depends_on: + db: + condition: service_healthy + networks: + - backend + - frontend + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/current-date').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + + client: + build: + context: . + dockerfile: Dockerfile.client + depends_on: + server: + condition: service_healthy + ports: + - "8080:80" + networks: + - frontend + +volumes: + db_data: + +networks: + backend: + internal: true + frontend: -- 2.49.1 From 22b592e3ef179caf1b2b820416122f0652bd249f Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:50:34 +0300 Subject: [PATCH 5/8] Remove slnx from project --- .dockerignore | 3 +-- DELIVER_MAN.slnx | 6 ------ 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 DELIVER_MAN.slnx diff --git a/.dockerignore b/.dockerignore index b1239e9..c846832 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,4 @@ node_modules .git .gitignore .gitattributes -README.md -DELIVER_MAN.slnx +README.md \ No newline at end of file diff --git a/DELIVER_MAN.slnx b/DELIVER_MAN.slnx deleted file mode 100644 index 97cca5d..0000000 --- a/DELIVER_MAN.slnx +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - -- 2.49.1 From 8b0fa219aa27e1414e6172aa6957948d135bf753 Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:50:42 +0300 Subject: [PATCH 6/8] Update README.md --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d3e31a8..95dd205 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ -# DELIVER_MAN \ No newline at end of file +# DELIVER_MAN + +## Docker + +Run client + server + database: + +```bash +docker compose up --build +``` + +Open the client at `http://localhost:8080`. + +Notes: +- The API is proxied through the client container to the server at `/api`. +- The MariaDB schema/data is auto-initialized from `docker/init.sql` on first run. -- 2.49.1 From 75df8970fd162fc764334d6da7e36d959a064844 Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 19:54:48 +0300 Subject: [PATCH 7/8] Move client and server dockerfile to special path --- docker-compose.yml | 4 ++-- Dockerfile.client => docker/Dockerfile.client | 0 Dockerfile.server => docker/Dockerfile.server | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename Dockerfile.client => docker/Dockerfile.client (100%) rename Dockerfile.server => docker/Dockerfile.server (100%) diff --git a/docker-compose.yml b/docker-compose.yml index 9e06a42..09fd699 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: server: build: context: . - dockerfile: Dockerfile.server + dockerfile: docker/Dockerfile.server environment: HOST: db PORT: 3306 @@ -42,7 +42,7 @@ services: client: build: context: . - dockerfile: Dockerfile.client + dockerfile: docker/Dockerfile.client depends_on: server: condition: service_healthy diff --git a/Dockerfile.client b/docker/Dockerfile.client similarity index 100% rename from Dockerfile.client rename to docker/Dockerfile.client diff --git a/Dockerfile.server b/docker/Dockerfile.server similarity index 100% rename from Dockerfile.server rename to docker/Dockerfile.server -- 2.49.1 From 23c67c9e6e8cc779f6f466f4f73ba8ea7535a5f7 Mon Sep 17 00:00:00 2001 From: ParkSuMin Date: Sat, 3 Jan 2026 20:41:15 +0300 Subject: [PATCH 8/8] Fix button logic --- public/app.js | 56 ++++++++++++++++++++++++++++++------------- server/db/database.js | 54 ++++++++++++++++++++++++++++++++++++++++- server/server.js | 12 ++++++---- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/public/app.js b/public/app.js index 256d506..75338f4 100644 --- a/public/app.js +++ b/public/app.js @@ -121,7 +121,7 @@ class OrderCard { idCell.textContent = item.product_id ?? item.id; const productCell = document.createElement('td'); - const productSelect = this.buildProductSelect(item); + const productSelect = this.buildProductSelect(item.product_id); productSelect.classList.add('form-select-sm'); productSelect.dataset.action = 'item-product'; productSelect.dataset.itemId = item.id; @@ -220,7 +220,12 @@ class OrderCard { const productLabel = document.createElement('label'); productLabel.className = 'form-label'; productLabel.textContent = 'Товар'; - const productSelect = this.buildProductSelect(); + const usedProductIds = new Set( + (this.order.items || []) + .map((item) => item.product_id) + .filter((value) => value !== null && value !== undefined) + ); + const productSelect = this.buildProductSelect(undefined, usedProductIds); const productSelectId = `add-item-product-${this.order.id}`; productSelect.name = 'productId'; productSelect.id = productSelectId; @@ -253,6 +258,16 @@ class OrderCard { addButton.textContent = 'Добавить позицию'; actionCol.appendChild(addButton); + if (productSelect.options.length === 0) { + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Нет доступных позиций'; + productSelect.appendChild(placeholder); + productSelect.disabled = true; + quantityInput.disabled = true; + addButton.disabled = true; + } + form.appendChild(productCol); form.appendChild(quantityCol); form.appendChild(actionCol); @@ -260,10 +275,13 @@ class OrderCard { return form; } - buildProductSelect(selectedId) { + buildProductSelect(selectedId, excludedIds) { const select = document.createElement('select'); select.className = 'form-select'; this.products.forEach((product) => { + if (excludedIds && excludedIds.has(product.id)) { + return; + } const option = document.createElement('option'); option.value = product.id; option.textContent = product.name; @@ -313,7 +331,7 @@ async function fetchJson(url, options) { const payload = await response.json().catch(() => ({ message: 'Ошибка запроса.' })); - throw new Error(payload.message || 'Ошибка запроса.'); + throw new Error(payload.error || payload.message || 'Error'); } if (response.status === 204) { return null; @@ -549,18 +567,24 @@ function initializeApp() { `[data-action="item-product"][data-item-id="${itemId}"]` ); - await fetchJson(`/api/orders/${orderId}/items/${itemId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - productId: productSelect.value, - quantity: Number.parseInt(quantityInput.value, 10) - }) - }); - await refreshData(); - return; + try { + await fetchJson(`/api/orders/${orderId}/items/${itemId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + productId: productSelect.value, + quantity: Number.parseInt(quantityInput.value, 10) + }) + }); + await refreshData(); + return; + } catch (error) { + showNotification(error.message, 'warning'); + await refreshData(); + return; + } } case 'delete-item': { await fetchJson(`/api/orders/${orderId}/items/${itemId}`, { diff --git a/server/db/database.js b/server/db/database.js index 5d11592..066b0cb 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -384,10 +384,17 @@ export default class DBAdapter { } } - async updateOrderItem({ itemId, quantity }) { + async updateOrderItem({ itemId, quantity, productId }) { let connection; try { + if (!itemId || !Number.isFinite(quantity) || quantity <= 0) { + return Promise.reject({ + type: DB_USER_ERROR, + error: new Error("Invalid item or quantity") + }); + } + connection = await this.#pool.getConnection(); await connection.beginTransaction(); @@ -406,6 +413,51 @@ export default class DBAdapter { }); } + const targetProductId = productId ?? itemRow.product_id; + + if (targetProductId !== itemRow.product_id) { + const newProduct = await connection.query( + 'SELECT quantity FROM products WHERE id = ? FOR UPDATE', + [targetProductId] + ); + const newProductRow = newProduct?.[0]; + if (!newProductRow) { + await connection.rollback(); + connection.release(); + return Promise.reject({ + type: DB_USER_ERROR, + error: new Error("Product not found") + }); + } + if (newProductRow.quantity < quantity) { + await connection.rollback(); + connection.release(); + return Promise.reject({ + type: DB_USER_ERROR, + error: new Error("Insufficient product quantity") + }); + } + + await connection.query( + 'UPDATE products SET quantity = quantity + ? WHERE id = ?', + [itemRow.quantity, itemRow.product_id] + ); + + await connection.query( + 'UPDATE products SET quantity = quantity - ? WHERE id = ?', + [quantity, targetProductId] + ); + + await connection.query( + 'UPDATE order_items SET product_id = ?, quantity = ? WHERE id = ?', + [targetProductId, quantity, itemId] + ); + + await connection.commit(); + connection.release(); + return; + } + const diff = quantity - itemRow.quantity; if (diff > 0) { diff --git a/server/server.js b/server/server.js index bdf7ee8..4309347 100644 --- a/server/server.js +++ b/server/server.js @@ -312,24 +312,28 @@ app.post('/api/orders/:orderId/items', async (req, res) => { app.put('/api/orders/:orderId/items/:itemId', async (req, res) => { let itemId = req.params.itemId; - let quantity = req.body.quantity; + let { quantity, productId } = req.body; + quantity = Number.parseInt(quantity, 10); try { await adapter.updateOrderItem({ itemId, + productId, quantity }); res.status(201).json({ itemId, + productId, quantity }); } catch (err) { - res.json({ + const statusCode = err.type === DB_USER_ERROR ? 400 : 500; + res.status(statusCode).json({ timeStamp: new Date().toISOString(), - statusCode: 500, - error: err.message + statusCode, + error: err.error?.message || err.message }); } }); -- 2.49.1