diff --git a/.env.example b/.env.example index 4623955..aef99c3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +VITE_ENABLE_DEMO=false VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key APP_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/.gitignore b/.gitignore index 5e3a82f..c2ad370 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ node_modules dist .env -.env.local +.env.* +!.env.example .DS_Store .worktrees .superpowers +.ruff_cache diff --git a/1 b/1 new file mode 100644 index 0000000..0083c04 --- /dev/null +++ b/1 @@ -0,0 +1,24 @@ +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the group registry only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the delivery registry only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + diff --git a/Caddyfile b/Caddyfile index 90321ea..8c1463e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -3,4 +3,14 @@ root * /usr/share/caddy file_server +header { + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-XSS-Protection "0" + Cross-Origin-Opener-Policy "same-origin" +} + try_files {path} /index.html diff --git a/Dockerfile b/Dockerfile index 3b71083..9eee5ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,3 +11,4 @@ FROM caddy:2-alpine COPY --from=build /app/dist /usr/share/caddy COPY Caddyfile /etc/caddy/Caddyfile EXPOSE 80 +USER 1000:1000 diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 545ba61..a477ee3 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -15,6 +15,17 @@ services: - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.service=supersam-app - traefik.http.services.supersam-app.loadbalancer.server.port=80 + # Redirect HTTP to HTTPS + - traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`) + - traefik.http.routers.supersam-app-http.entryPoints=http + - traefik.http.routers.supersam-app-http.middlewares=redirect-to-https + - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https + - traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true + # Security headers via Traefik + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin + - traefik.http.routers.supersam-app.middlewares=supersam-sec networks: coolify: diff --git a/docs/sql/public-delivery-choice-rpc.sql b/docs/sql/public-delivery-choice-rpc.sql index 87ab1fe..5694c9b 100644 --- a/docs/sql/public-delivery-choice-rpc.sql +++ b/docs/sql/public-delivery-choice-rpc.sql @@ -77,12 +77,11 @@ begin ); v_customer_name := coalesce( nullif(v_group.customer_name, ''), - nullif(v_group.customer ->> 'name', ''), nullif(v_invitation.customer_name, '') ); v_customer_phone := coalesce( nullif(v_group.customer_phone, ''), - nullif(v_group.customer ->> 'phone', ''), + nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '') ); select coalesce( diff --git a/index.html b/index.html index 62be73b..91bbccc 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,13 @@ Construction Delivery Control +
diff --git a/package-lock.json b/package-lock.json index b3d0183..47d15c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.7.4", + "playwright": "^1.60.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.3.0", @@ -4692,6 +4693,50 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 363ec1f..3248eb3 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,15 @@ "anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs" }, "dependencies": { - "@supabase/supabase-js": "^2.52.0", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "framer-motion": "^12.7.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^7.3.0", - "tailwind-merge": "^3.3.0" + "@supabase/supabase-js": "2.52.0", + "clsx": "2.1.1", + "date-fns": "4.1.0", + "framer-motion": "12.7.4", + "playwright": "1.60.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "7.3.0", + "tailwind-merge": "3.3.0" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/public/service-worker.js b/public/service-worker.js index b90d62c..1d6ea31 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,75 +1,82 @@ -const STATIC_CACHE = "construction-delivery-static-v1"; -const RUNTIME_CACHE = "construction-delivery-runtime-v1"; -const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; +const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; -self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()), - ); -}); +if (!isLocalhost) { + const STATIC_CACHE = "construction-delivery-static-v1"; + const RUNTIME_CACHE = "construction-delivery-runtime-v1"; + const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; -self.addEventListener("activate", (event) => { - event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key)) - .map((key) => caches.delete(key)), - ), - ) - .then(() => self.clients.claim()) - .then(async () => { - const clients = await self.clients.matchAll({ includeUncontrolled: true }); - clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" })); - }), - ); -}); + self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()), + ); + }); -self.addEventListener("fetch", (event) => { - if (event.request.method !== "GET") { - return; - } + self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key)) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()) + .then(async () => { + const clients = await self.clients.matchAll({ includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" })); + }), + ); + }); - const requestUrl = new URL(event.request.url); - const isSameOrigin = requestUrl.origin === self.location.origin; + self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") { + return; + } + + const requestUrl = new URL(event.request.url); + const isSameOrigin = requestUrl.origin === self.location.origin; + + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request) + .then((response) => { + const responseClone = response.clone(); + caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone)); + return response; + }) + .catch(async () => { + const cachedPage = await caches.match(event.request); + return cachedPage || caches.match("/index.html"); + }), + ); + return; + } + + if (!isSameOrigin) { + return; + } - if (event.request.mode === "navigate") { event.respondWith( - fetch(event.request) - .then((response) => { + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(event.request).then((response) => { + if (!response || response.status !== 200) { + return response; + } + const responseClone = response.clone(); caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone)); return response; - }) - .catch(async () => { - const cachedPage = await caches.match(event.request); - return cachedPage || caches.match("/index.html"); - }), + }); + }), ); - return; - } - - if (!isSameOrigin) { - return; - } - - event.respondWith( - caches.match(event.request).then((cachedResponse) => { - if (cachedResponse) { - return cachedResponse; - } - - return fetch(event.request).then((response) => { - if (!response || response.status !== 200) { - return response; - } - - const responseClone = response.clone(); - caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone)); - return response; - }); - }), - ); -}); + }); +} else { + self.addEventListener("install", (event) => self.skipWaiting()); + self.addEventListener("activate", (event) => self.clients.claim()); +} diff --git a/src/components/UI/Badge.jsx b/src/components/UI/Badge.jsx index c702a58..5555108 100644 --- a/src/components/UI/Badge.jsx +++ b/src/components/UI/Badge.jsx @@ -10,6 +10,7 @@ export const Badge = ({ children, tone = "neutral", className }) => { "border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent", "border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger", "border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning", + "border-[rgba(33,111,191,0.22)] bg-[rgba(33,111,191,0.12)] text-[#216fbf]": tone === "info", "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral", }, className, diff --git a/src/components/UI/Panel.jsx b/src/components/UI/Panel.jsx index 0959cb6..2364703 100644 --- a/src/components/UI/Panel.jsx +++ b/src/components/UI/Panel.jsx @@ -5,7 +5,7 @@ export const Panel = ({ children, className, ...props }) => { return (
{ +export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => { const [filters, setFilters] = React.useState({ - dateFrom: "", - dateTo: "", - deliveryHalfDay: "all", + selectedDate: "", deliveryStatus: "all", }); - const agreedOrderGroups = React.useMemo( - () => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)), - [orderGroups], + const driverOrderGroups = React.useMemo( + () => orderGroups.filter((group) => { + const isVisible = isOrderGroupVisibleToDriver(group); + const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id; + return isVisible && isAssignedToMe; + }), + [orderGroups, currentUser], ); - const filteredOrderGroups = React.useMemo( - () => - filterOrderGroups(agreedOrderGroups, { - dateFrom: filters.dateFrom, - dateTo: filters.dateTo, - deliveryHalfDay: filters.deliveryHalfDay, - deliveryStatus: filters.deliveryStatus, - }), - [agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus], - ); + // Build map of date -> count for quick lookup + const dateDeliveryMap = React.useMemo(() => { + const map = new Map(); + driverOrderGroups.forEach((group) => { + const date = group.deliveryDate; + if (date) { + map.set(date, (map.get(date) || 0) + 1); + } + }); + return map; + }, [driverOrderGroups]); + + const sortedDeliveryDates = React.useMemo(() => { + return Array.from(dateDeliveryMap.keys()).sort(); + }, [dateDeliveryMap]); + + const filteredOrderGroups = React.useMemo(() => { + let result = [...driverOrderGroups]; + if (filters.selectedDate) { + result = result.filter((group) => group.deliveryDate === filters.selectedDate); + } + if (filters.deliveryStatus !== "all") { + result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus); + } + return result; + }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus]); const groupedOrderGroups = React.useMemo( () => groupOrderGroupsByDate(filteredOrderGroups), [filteredOrderGroups], ); + const deliveryCountLabel = `${filteredOrderGroups.length} ${ filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок" }`; + const isDateSelected = (date) => filters.selectedDate === date; + return (
@@ -65,49 +84,22 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => { {deliveryCountLabel}

- Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня. + Показываем только согласованные к доставке группы. Выберите дату ниже.

-
+
- -
+ + {/* Date pills showing days with deliveries */} + {sortedDeliveryDates.length > 0 && ( +
+ + {sortedDeliveryDates.map((date) => { + const count = dateDeliveryMap.get(date) || 0; + const selected = isDateSelected(date); + return ( + + ); + })} +
+ )}
diff --git a/src/components/driver/DriverDeliveryPlanner.test.jsx b/src/components/driver/DriverDeliveryPlanner.test.jsx index dcfb565..ee77d28 100644 --- a/src/components/driver/DriverDeliveryPlanner.test.jsx +++ b/src/components/driver/DriverDeliveryPlanner.test.jsx @@ -18,6 +18,7 @@ const orderGroups = [ notReadyCount: 0, status: "ready_for_notification", deliveryStatus: "agreed", + assignedDriverId: "driver-1", deliveryHalfDay: "Первая половина дня", smsSentAt: null, updatedAt: "2026-04-16T12:00:00Z", @@ -47,6 +48,7 @@ describe("DriverDeliveryPlanner", () => { {}} + currentUser={{ id: "driver-1" }} />, ); @@ -55,8 +57,7 @@ describe("DriverDeliveryPlanner", () => { expect(markup).toContain("Мария Волкова"); expect(markup).toContain("CD-240031"); expect(markup).not.toContain("Не показывать"); - expect(markup).toContain("Дата от"); - expect(markup).toContain("Время суток"); + expect(markup).toContain("Дата"); expect(markup).toContain("Статус"); expect(markup).toContain("Согласовано"); expect(markup).not.toContain("Канбан"); diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 46c01a9..e2afd7c 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -1,22 +1,15 @@ import React from "react"; import { - buildOrderGroupBuckets, filterOrderGroups, getOrderGroupDisplayStatusLabel, + getOrderGroupDisplayStatusValue, getOrderGroupStatusTone, - ORDER_GROUP_BUCKET_LABELS, ORDER_GROUP_DISPLAY_STATUS_OPTIONS, } from "../../services/orderGroupViews"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { OrderFilters } from "../orders/OrderFilters"; -const BUCKET_ICONS = { - ready_to_launch: "\u2713", - sms_sent: "\u2709", - manual_work: "\u26A0", -}; - const renderOrderNumbers = (group) => { if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) { return Номера не указаны; @@ -36,20 +29,28 @@ const renderOrderNumbers = (group) => { ); }; -export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => { +export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => { const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" }); const filteredGroups = React.useMemo( () => filterOrderGroups(orderGroups, filters), [filters, orderGroups], ); - const deliveryGroupBuckets = React.useMemo( - () => buildOrderGroupBuckets(filteredGroups), - [filteredGroups], - ); - const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS); - const buckets = deliveryGroupBuckets || {}; + // Group by display status value + const statusGroups = React.useMemo(() => { + const map = new Map(); + for (const group of filteredGroups) { + const statusValue = getOrderGroupDisplayStatusValue(group); + if (!map.has(statusValue)) { + const label = getOrderGroupDisplayStatusLabel(group); + map.set(statusValue, { label, groups: [] }); + } + map.get(statusValue).groups.push(group); + } + return map; + }, [filteredGroups]); + const totalGroups = filteredGroups.length; return ( @@ -65,39 +66,24 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => { {!totalGroups ? ( - +
По этому поиску ничего не найдено. - +
) : (
- {bucketKeys.map((bucketKey) => { - const groups = buckets[bucketKey] || []; - const label = ORDER_GROUP_BUCKET_LABELS[bucketKey]; - const icon = BUCKET_ICONS[bucketKey]; - - if (!groups.length) { - return ( - -
- {icon} -

{label}

-
-

Нет групп

-
- ); - } + {Array.from(statusGroups.entries()).map(([statusValue, { label, groups }]) => { + if (!groups.length) return null; return ( -
+
- {icon}

{label}

- {groups.length} + {groups.length}
{groups.map((group) => ( diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 71eacf1..a498d8f 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -2,6 +2,7 @@ import React from "react"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; +import { Select } from "../UI/Select"; import { Panel } from "../UI/Panel"; import { getOrderGroupDeliveryStatusLabel, @@ -98,6 +99,14 @@ export const getNextSelectableDateKey = (referenceDate = new Date()) => { return toDateKey(current); }; +const normalizePhoneForTel = (phone) => { + const cleaned = String(phone || "").trim(); + if (!cleaned) return ""; + if (cleaned.startsWith("+7")) return cleaned; + if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1); + return "+7" + cleaned; +}; + const isFutureDeliveryDate = (value) => { const parsedDate = fromDateKey(value); @@ -190,11 +199,17 @@ export const OrderDetailPanel = ({ canManageDelivery = false, onSaveManualDeliveryChoice, isSavingDeliveryChoice = false, + drivers = [], + onAssignDriver, + onChangeDeliveryStatus, + userRole, }) => { const [deliveryDate, setDeliveryDate] = React.useState(""); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [formMessage, setFormMessage] = React.useState(""); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); + const [driverMessage, setDriverMessage] = React.useState(""); + const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const [currentMonth, setCurrentMonth] = React.useState(() => { const existingDeliveryDate = fromDateKey(order?.deliveryDate); @@ -216,6 +231,10 @@ export const OrderDetailPanel = ({ ); const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); + React.useEffect(() => { + setSelectedDriverId(order?.assignedDriverId || ""); + }, [order?.assignedDriverId]); + React.useEffect(() => { const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); const nextSelectableDateKey = getNextSelectableDateKey(); @@ -270,6 +289,25 @@ export const OrderDetailPanel = ({ } }; + const handleAssignDriver = async () => { + if (!selectedDriverId) { + setDriverMessage("Выберите водителя"); + return; + } + + setDriverMessage(""); + const response = await onAssignDriver({ + orderGroupId: order.id, + driverId: selectedDriverId, + }); + + if (!response.success) { + setDriverMessage(response.error || "Не удалось назначить водителя"); + } else { + setDriverMessage("Водитель назначен"); + } + }; + return (
@@ -288,65 +326,160 @@ export const OrderDetailPanel = ({ {getOrderGroupDisplayStatusLabel(order)}
-
+

Дата доставки

-

{formatDeliveryDateDisplay(order.deliveryDate)}

+

{formatDeliveryDateDisplay(order.deliveryDate)}

Время доставки

-

{renderValue(order.deliveryTime || order.deliveryHalfDay)}

+

{renderValue(order.deliveryTime || order.deliveryHalfDay)}

+
+
+

+ Водитель +

+

{order.assignedDriverId ? renderValue(order.assignedDriverName) : "Не назначен"}

+
+ +
+

+ Адрес доставки +

+

{renderValue(order.deliveryAddress)}

-
+
-

Группа

-

{renderValue(order.groupKey)}

+

Номер счёта

+

{renderValue(order.orderNumberSummary)}

Клиент

-

{renderValue(order.customerName)}

+

{renderValue(order.customerName)}

-

Телефон

-

{renderValue(order.customerPhone)}

-
-
-

Дата

-

{renderValue(order.customerDate)}

-
-
-

Адрес доставки

-

{renderValue(order.deliveryAddress)}

+

Дата счёта

+

{renderValue(order.customerDate)}

Всего заказов

-

{order.ordersCount ?? 0}

+

{order.ordersCount ?? 0}

Готово

-

{order.readyCount ?? 0}

+

{order.readyCount ?? 0}

Не готово

-

{order.notReadyCount ?? 0}

+

{order.notReadyCount ?? 0}

Обновлена

-

{formatDateTime(order.updatedAt)}

+

{formatDateTime(order.updatedAt)}

Статус доставки

-

{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}

+

{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}

+ {canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? ( + +
+ Назначение водителя +

+ {order.assignedDriverId + ? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.` + : "Выберите водителя для доставки."} +

+
+
+ + +
+ {driverMessage ? ( +

{driverMessage}

+ ) : null} +
+ ) : null} + + {userRole === "driver" && order && onChangeDeliveryStatus ? ( + +
+ Статус доставки +

+ Обновите статус по мере выполнения доставки. +

+
+
+ {[ + { value: "loaded", label: "Загружено" }, + { value: "on_route", label: "В пути" }, + { value: "delivered", label: "Доставлено" }, + { value: "problem", label: "Проблема" }, + { value: "cancelled", label: "Отменено" }, + ].map((statusOption) => ( + + ))} +
+
+ ) : null} + {canManageDelivery ? (
@@ -521,12 +654,31 @@ export const OrderDetailPanel = ({ {renderList(order.orderNumbers)} - - Дополнительные данные -
+ {userRole !== "driver" ? ( + + Дополнительные данные +
+ {order.firstSmsSentAt ? ( +
+

1-е SMS отправлено

+

{formatDateTime(order.firstSmsSentAt)}

+
+ ) : null} + {order.secondSmsSentAt ? ( +
+

2-е SMS отправлено

+

{formatDateTime(order.secondSmsSentAt)}

+
+ ) : null} + {!order.firstSmsSentAt && !order.secondSmsSentAt ? ( +
+

SMS отправлено

+

Нет

+
+ ) : null}
-

SMS отправлено

-

{order.smsSentAt ? "Да" : "Нет"}

+

Ручное согласование выполнено

+

{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}

{order.createdFromExchangeAt ? (
@@ -534,14 +686,9 @@ export const OrderDetailPanel = ({

{formatDateTime(order.createdFromExchangeAt)}

) : null} - {order.sourceKey ? ( -
-

Ключ источника

-

{order.sourceKey}

-
- ) : null} -
-
+
+
+ ) : null}
); }; diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index c50e80c..77af809 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -1,5 +1,4 @@ import React from "react"; -import { Badge } from "../UI/Badge"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; @@ -39,22 +38,10 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { setFilters((current) => ({ ...current, [key]: value })); }; - const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean); - return ( -
- updateFilter("query", event.target.value)} - /> - +
- - Статус - ); @@ -106,15 +96,13 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
) : null}
+ updateFilter("query", event.target.value)} + />
- - {activeChips.length ? ( -
- {activeChips.map((chip) => ( - {chip.label} - ))} -
- ) : null}
); }; diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index eda78f0..532c7df 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -50,9 +50,9 @@ export const OrdersTable = ({
{!orderGroups.length ? ( - +
Группы не найдены. Попробуйте изменить поиск или статус. - +
) : null} {orderGroups.map((group) => (
); @@ -150,7 +168,7 @@ export const DashboardPage = () => { {renderActiveSection()} - setIsGroupModalOpen(false)}> + setIsGroupModalOpen(false)} className="md:max-w-[800px]">
@@ -170,6 +188,10 @@ export const DashboardPage = () => { canManageDelivery={["manager", "logistician", "admin"].includes(userRole)} onSaveManualDeliveryChoice={saveManualDeliveryChoice} isSavingDeliveryChoice={isSavingDeliveryChoice} + drivers={drivers} + onAssignDriver={assignDriver} + onChangeDeliveryStatus={changeDeliveryStatus} + userRole={userRole} />
diff --git a/src/pages/DashboardPage.test.jsx b/src/pages/DashboardPage.test.jsx index 7f47832..2e6d28b 100644 --- a/src/pages/DashboardPage.test.jsx +++ b/src/pages/DashboardPage.test.jsx @@ -44,6 +44,7 @@ const baseGroup = { status: "ready_for_notification", deliveryStatus: "agreed", delivery_status: "agreed", + assignedDriverId: "u-driver", deliveryDate: "2026-04-16", deliveryTime: "Первая половина дня", updatedAt: "2026-04-15T09:00:00Z", @@ -76,6 +77,7 @@ const mockOrderGroupsState = { loadError: "", saveManualDeliveryChoice: vi.fn(), isSavingDeliveryChoice: false, + assignDriver: vi.fn(), }; describe("DashboardPage", () => { @@ -130,7 +132,7 @@ describe("DashboardPage", () => { ); expect(markup).toContain("Наборы доставки"); - expect(markup).toContain("Готовы к уведомлению"); + expect(markup).toContain("Согласовано"); expect(markup).not.toContain("Управление ботами"); expect(markup).not.toContain("рабочая панель"); expect(markup).not.toContain("Сегодня"); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index a008d4a..d5215c6 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -77,7 +77,7 @@ export const LoginPage = () => { error={displayError} /> - {(isDemoMode || import.meta.env.DEV) ? ( + {(isDemoMode || import.meta.env.DEV === true && import.meta.env.VITE_ENABLE_DEMO === 'true') ? (

{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"} diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 7c323ec..e00acb4 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -4,6 +4,7 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus export const DELIVERY_GROUP_STATUS_LABELS = { pending_confirmation: "Ожидает согласования", + manual_confirmation_required: "Требуется ручное подтверждение", agreed: "Согласовано", driver_assigned: "Назначен водитель", loaded: "Загружено", @@ -13,6 +14,16 @@ export const DELIVERY_GROUP_STATUS_LABELS = { cancelled: "Отменено", }; +export const NOTIFICATION_STATUS_LABELS = { + not_started: "", + link_ready: "Ссылка готова", + first_sms_sent: "1-е приглашение отправлено", + second_sms_sent: "2-е приглашение отправлено", + send_failed: "Ошибка отправки", + confirmed: "Согласовано", + manual_required: "Переведено в ручное", +}; + export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ "agreed", "driver_assigned", @@ -46,7 +57,7 @@ const normalizeDeliveryHalfDayLabel = (value) => { return HALF_DAY_LABELS.afternoon; } - return normalized; + return ""; }; const parseJsonIfNeeded = (value) => { @@ -133,13 +144,19 @@ export const getOrderGroupDisplayStatusLabel = (group) => { return getOrderGroupDeliveryStatusLabel(deliveryStatus); } + const notificationStatus = group?.notificationStatus || group?.notification_status; + const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus]; + if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") { + return notificationLabel; + } + return getOrderGroupStatusLabel(group?.status); }; export const getOrderGroupDisplayStatusValue = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status; - if (deliveryStatus && deliveryStatus !== "pending_confirmation") { + if (deliveryStatus) { return `delivery:${deliveryStatus}`; } @@ -269,11 +286,13 @@ export const ORDER_GROUP_STATUS_LABELS = { ready_for_notification: "Готово к уведомлению", sms_sent: "SMS отправлены", manual_work: "Нужна ручная работа", + ready_to_launch: "Готово к запуску", }; export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ { value: "all", label: "Все статусы" }, - { value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification }, + { value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation }, + { value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required }, { value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed }, { value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned }, { value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded }, @@ -281,31 +300,34 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ { value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered }, { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, - { value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent }, - { value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work }, ]; export const getOrderGroupStatusLabel = (status) => ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно"; export const getOrderGroupDeliveryStatusTone = (status) => { - if (status === "agreed") { - return "accent"; + switch (status) { + case "pending_confirmation": + return "neutral"; + case "manual_confirmation_required": + return "warning"; + case "agreed": + return "accent"; + case "driver_assigned": + return "info"; + case "loaded": + return "info"; + case "on_route": + return "accent"; + case "delivered": + return "accent"; + case "problem": + return "danger"; + case "cancelled": + return "danger"; + default: + return "neutral"; } - - if (status === "problem") { - return "warning"; - } - - if (status === "delivered") { - return "accent"; - } - - if (status === "cancelled") { - return "danger"; - } - - return "neutral"; }; export const groupOrderGroupsByDate = (groups) => { @@ -343,6 +365,12 @@ export const groupOrderGroupsByDate = (groups) => { }; const getBucketKey = (group) => { + const notificationStatus = group?.notificationStatus || group?.notification_status; + + if (notificationStatus === "manual_required") { + return "manual_work"; + } + if (group.smsSentAt) { return "sms_sent"; } @@ -397,6 +425,14 @@ export const getOrderGroupStatusTone = (group) => { return getOrderGroupDeliveryStatusTone(deliveryStatus); } + const notificationStatus = group?.notificationStatus || group?.notification_status; + if (notificationStatus === "send_failed" || notificationStatus === "manual_required") { + return "warning"; + } + if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") { + return "accent"; + } + if (group.smsSentAt) { return "accent"; } diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 2a26538..90be240 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -1,4 +1,5 @@ import { safeSupabaseCall } from "../safeSupabaseCall"; +import logger from "../../utils/logger"; import { hasSupabaseConfig, supabase } from "../../supabaseClient"; import { getOrderGroupDeliveryHalfDay, @@ -77,7 +78,16 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { : ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay) ? rawDeliveryHalfDay : ""; - const deliveryAddress = normalizeText(row.delivery_address); + + const extractAddressFromSourceOrders = (sourceOrders) => { + if (!Array.isArray(sourceOrders) || !sourceOrders.length) { + return ""; + } + const first = sourceOrders[0]; + return normalizeText(first.adress || first.address || ""); + }; + + const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders); return { id: row.id, @@ -96,12 +106,18 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone), customerDate, deliveryAddress, + assignedDriverId: row.assigned_driver_id || null, + assignedDriverName: row.assigned_driver?.name || "", ordersCount, readyCount, notReadyCount, orderNumbers, status: row.status || "draft", smsSentAt: row.sms_sent_at || null, + firstSmsSentAt: row.first_sms_sent_at || null, + secondSmsSentAt: row.second_sms_sent_at || null, + manualConfirmationAt: row.manual_confirmation_at || null, + notificationStatus: normalizeText(row.notification_status), createdFromExchangeAt: row.created_from_exchange_at || null, sourceKey: row.source_key || null, legacyCustomerName: row.legacy_customer_name || null, @@ -148,6 +164,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { sourceOrders: row.source_orders, }), getOrderGroupDeliveryStatusLabel(deliveryStatus), + row.notification_status, + extractAddressFromSourceOrders(row.source_orders), ] .filter(Boolean) .join(" ") @@ -162,7 +180,7 @@ export const updateOrderGroupDeliveryChoice = async ({ }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); - const { data, error } = await client + const updateResult = await client .from("order_groups") .update({ delivery_status: "agreed", @@ -171,8 +189,16 @@ export const updateOrderGroupDeliveryChoice = async ({ notification_status: "confirmed", updated_at: new Date().toISOString(), }) + .eq("id", orderGroupId); + + if (updateResult.error) { + throw updateResult.error; + } + + const { data, error } = await client + .from("order_groups") + .select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .eq("id", orderGroupId) - .select("*") .single(); if (error) { @@ -183,18 +209,97 @@ export const updateOrderGroupDeliveryChoice = async ({ }, "Ошибка сохранения согласования доставки"); }; +export const assignDriverToOrderGroup = async ({ + orderGroupId, + driverId, +}) => { + return safeSupabaseCall(async () => { + const client = requireSupabase(); + + logger.debug("[assignDriver] orderGroupId:", orderGroupId, "driverId:", driverId); + + // Use RPC to bypass RLS on order_groups update + const { data: rpcData, error: rpcError } = await client.rpc("assign_driver", { + p_order_group_id: orderGroupId, + p_driver_id: driverId || null, + }); + + logger.debug("[assignDriver] rpc result:", { rpcData, rpcError }); + if (rpcError) { + throw rpcError; + } + + if (!rpcData) { + throw new Error("Группа не найдена"); + } + + // Fetch with driver join for the mapper + const { data, error } = await client + .from("order_groups") + .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") + .eq("id", orderGroupId) + .single(); + + if (error) { + throw error; + } + + return mapOrderGroupRowToDeliveryGroup(data); + }, "Ошибка назначения водителя"); +}; + +export const updateDeliveryStatus = async ({ orderGroupId, status }) => { + return safeSupabaseCall(async () => { + const client = requireSupabase(); + const { data: rpcData, error: rpcError } = await client.rpc("update_delivery_status", { + p_order_group_id: orderGroupId, + p_status: status, + }); + + if (rpcError) { + throw rpcError; + } + + // Fetch updated group + const { data, error } = await client + .from("order_groups") + .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") + .eq("id", orderGroupId) + .single(); + + if (error) { + throw error; + } + + return mapOrderGroupRowToDeliveryGroup(data); + }, "Ошибка обновления статуса доставки"); +}; + export const fetchOrderGroups = async () => { return safeSupabaseCall(async () => { const client = requireSupabase(); const { data, error } = await client .from("order_groups") - .select("*") + .select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .order("updated_at", { ascending: false }); if (error) { throw error; } - return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean); + // Load driver names to patch groups where assigned_driver join is missing + const { data: drivers, error: driversError } = await client.rpc("get_drivers"); + const driverMap = new Map(); + if (!driversError && drivers) { + drivers.forEach((d) => driverMap.set(d.id, d.name || d.email)); + } + + return (data || []).map((row) => { + const group = mapOrderGroupRowToDeliveryGroup(row); + if (group && group.assignedDriverId && !group.assignedDriverName) { + group.assignedDriverName = driverMap.get(group.assignedDriverId) || ""; + } + return group; + }).filter(Boolean); }, "Ошибка загрузки групп доставки"); }; diff --git a/src/services/supabase/orderGroupRepository.test.js b/src/services/supabase/orderGroupRepository.test.js index 2ded6f8..8da0bbd 100644 --- a/src/services/supabase/orderGroupRepository.test.js +++ b/src/services/supabase/orderGroupRepository.test.js @@ -116,10 +116,13 @@ describe("updateOrderGroupDeliveryChoice", () => { selectMock.mockReset(); singleMock.mockReset(); - fromMock.mockReturnValue({ update: updateMock }); + fromMock + .mockReturnValueOnce({ update: updateMock }) + .mockReturnValueOnce({ select: selectMock }); updateMock.mockReturnValue({ eq: eqMock }); - eqMock.mockReturnValue({ select: selectMock }); - selectMock.mockReturnValue({ single: singleMock }); + eqMock.mockReturnValueOnce({ error: null, status: 200, statusText: "OK" }) + .mockReturnValueOnce({ single: singleMock }); + selectMock.mockReturnValue({ eq: eqMock }); }); it("updates the group directly in order_groups", async () => { @@ -163,7 +166,7 @@ describe("updateOrderGroupDeliveryChoice", () => { updated_at: expect.any(String), }); expect(eqMock).toHaveBeenCalledWith("id", "group-id"); - expect(selectMock).toHaveBeenCalledWith("*"); + expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)"); expect(singleMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/services/supabase/orderRepository.js b/src/services/supabase/orderRepository.js index dc7fee3..d684c92 100644 --- a/src/services/supabase/orderRepository.js +++ b/src/services/supabase/orderRepository.js @@ -209,7 +209,7 @@ export const fetchOrders = async () => { const client = requireSupabase(); const { data, error } = await client .from("orders") - .select("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)") + .select("id, order_number, customer, status, delivery_agreement_status, manager_id, logistician_id, assigned_driver_id, ready_for_delivery_at, delivery_flow_started_at, delivery_flow_source, source_order_number, source_order_date, source_customer_name, source_customer_phone, source_customer_email, source_customer_city, source_total_sum, source_paid_at, source_gateway, source_associated_bills_text, source_production_at, source_saw_at, source_glue_at, source_h_glue_at, source_curve_at, source_accept_at, source_ship_at, source_sms_legacy_at, source_payload, delivery_set_key, delivery_set_name, delivery_set_status, delivery_set_ready_at, delivery_ready_reason, created_at, updated_at, order_history(id, action, old_status, new_status, user_id, user_name, metadata, created_at), delivery_slots(id, delivery_date, delivery_time, logistician_id, logistician_name, status, created_at, selected_by_client_at), chat_messages(id, sender_type, sender_name, channel, text, external_message_id, payload, created_at), order_logisticians(order_id, logistician_id)") .order("updated_at", { ascending: false }); if (error) { diff --git a/src/services/supabase/userRepository.js b/src/services/supabase/userRepository.js index 9231836..f2c49f9 100644 --- a/src/services/supabase/userRepository.js +++ b/src/services/supabase/userRepository.js @@ -1,4 +1,5 @@ import { safeSupabaseCall } from "../safeSupabaseCall"; +import logger from "../../utils/logger"; import { hasSupabaseConfig, supabase } from "../../supabaseClient"; const requireSupabase = () => { @@ -41,3 +42,28 @@ export const fetchUsers = async () => { return (data || []).map(mapUserRowToAppUser).filter(Boolean); }, "Ошибка загрузки пользователей"); }; + +export const fetchDrivers = async () => { + return safeSupabaseCall(async () => { + const client = requireSupabase(); + const { data, error } = await client.rpc("get_drivers"); + + logger.debug("[fetchDrivers] rpc raw:", { data, error }); + + if (error) { + throw error; + } + + const mapped = (data || []).map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + role: "driver", + lastLogin: null, + botBindings: null, + })); + + logger.debug("[fetchDrivers] mapped:", mapped); + return mapped; + }, "Ошибка загрузки водителей"); +}; diff --git a/src/utils/logger.js b/src/utils/logger.js index 7fd0fd7..e5c28f8 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,7 +1,12 @@ +const isDev = typeof import.meta !== "undefined" ? import.meta.env.DEV : true; + const logger = { info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""), error: (message, error) => console.error(`[error] ${message}`, error ?? ""), order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""), + debug: isDev + ? (message, payload) => console.debug(`[debug] ${message}`, payload ?? "") + : () => {}, }; export default logger; diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..3f309c0 --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.99.0 \ No newline at end of file diff --git a/supabase/functions/_shared/chatbot.ts b/supabase/functions/_shared/chatbot.ts index b3afc27..ba41bf3 100644 --- a/supabase/functions/_shared/chatbot.ts +++ b/supabase/functions/_shared/chatbot.ts @@ -1,4 +1,4 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8"; +import { createClient } from "@supabase/supabase-js"; import { getOrderUpdateForInboundAction } from "./workflow.ts"; export type ProviderName = "telegram" | "vk" | "messenger_max"; @@ -19,6 +19,13 @@ export const createServiceClient = () => { return createClient(supabaseUrl, serviceRoleKey); }; +/** Create a Supabase client that respects RLS policies (uses anon key). */ +export const createAnonClient = () => { + const supabaseUrl = Deno.env.get("SUPABASE_URL") || ""; + const anonKey = Deno.env.get("SUPABASE_ANON_KEY") || ""; + return createClient(supabaseUrl, anonKey); +}; + export const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts index 19af8f1..1b13906 100644 --- a/supabase/functions/_shared/security.ts +++ b/supabase/functions/_shared/security.ts @@ -104,12 +104,7 @@ const resolveAllowedOrigins = (mode: CorsMode) => { return Array.from(new Set(configured)); } - const currentMode = readEnv("NODE_ENV") || "development"; - if (currentMode === "production") { - return []; - } - - return [...DEFAULT_LOCAL_ORIGINS]; + return []; }; export class HttpError extends Error { @@ -341,6 +336,40 @@ export const maskOrderNumber = (orderNumber: string | null | undefined) => { return `…${value.slice(-4)}`; }; + + +export const isValidUuid = (value: string): boolean => { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +}; + +export const requireUuid = (value: string | undefined | null, label = "id"): string => { + const trimmed = (value || "").trim(); + if (!trimmed || !isValidUuid(trimmed)) { + throw new HttpError(400, `Invalid ${label} format`); + } + return trimmed; +}; + + +export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => { + const origin = request.headers.get("origin") || ""; + const host = request.headers.get("host") || ""; + if (!origin || !host) { + return false; + } + try { + const originHost = new URL(origin).host; + return allowedOrigins.some((allowed) => { + try { + return new URL(allowed).host === originHost; + } catch { + return allowed === origin; + } + }); + } catch { + return false; + } +}; export const requireRateLimit = async ( supabase: { rpc: ( diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 24254d3..5cfa666 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -4,6 +4,7 @@ import { isActiveInvitationState, isInvitationExpired, } from "../_shared/delivery-invitations.ts"; +import { isValidUuid, requireUuid } from "../_shared/security.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { @@ -14,6 +15,7 @@ import { preflightResponse, readJsonBody, requireRateLimit, + requireSameOrigin, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 8 * 1024; @@ -65,6 +67,19 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } + const allowedOriginsForCsrf = ((): string[] => { + const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean); + const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || ""; + return [...envOrigins, appUrl].filter(Boolean); + })(); + + if (!requireSameOrigin(request, allowedOriginsForCsrf)) { + const origin = request.headers.get("origin") || ""; + if (origin) { + return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders); + } + } + try { const { body } = await readJsonBody(request, { maxBytes: MAX_BODY_BYTES, @@ -74,6 +89,14 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); } + if (body.orderGroupId) { + try { + requireUuid(body.orderGroupId, "orderGroupId"); + } catch (e) { + return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders); + } + } + const tokenHash = await hashInvitationToken(body.token); const supabase = createServiceClient(); const ipHash = await hashText(getClientIp(request)); diff --git a/supabase/functions/create-delivery-invitation/index.ts b/supabase/functions/create-delivery-invitation/index.ts index e168aaf..c19c3fb 100644 --- a/supabase/functions/create-delivery-invitation/index.ts +++ b/supabase/functions/create-delivery-invitation/index.ts @@ -19,6 +19,7 @@ import { } from "../_shared/security.ts"; const MAX_BODY_BYTES = 16 * 1024; +const MAX_SLOTS = 14; type CreateInvitationBody = { orderId?: string; @@ -150,7 +151,7 @@ const createOrderGroupInvitation = async ({ const publicBaseUrl = resolveRequiredPublicAppUrl(request); const url = buildInvitationUrl(publicBaseUrl, token); const availableSlots = body.availableSlots?.length - ? normalizeAvailableSlots(body.availableSlots) + ? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS) : buildDefaultDatedAvailableSlots(); const invitationPayload = { @@ -163,7 +164,7 @@ const createOrderGroupInvitation = async ({ customer_phone: customerPhone, customer_messenger: body.customerMessenger || null, available_slots: availableSlots, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), sent_at: null, }; @@ -321,7 +322,7 @@ Deno.serve(async (request) => { customer_phone: body.customerPhone || null, customer_messenger: body.customerMessenger || null, available_slots: normalizeAvailableSlots(body.availableSlots), - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), sent_at: new Date().toISOString(), }; diff --git a/supabase/functions/get-delivery-invitation/index.ts b/supabase/functions/get-delivery-invitation/index.ts index 3144e55..5595b95 100644 --- a/supabase/functions/get-delivery-invitation/index.ts +++ b/supabase/functions/get-delivery-invitation/index.ts @@ -8,6 +8,7 @@ import { isInvitationExpired, } from "../_shared/delivery-invitations.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; +import { isValidUuid } from "../_shared/security.ts"; import { getClientIp, getCorsHeaders, diff --git a/supabase/functions/import_map.json b/supabase/functions/import_map.json new file mode 100644 index 0000000..6f75758 --- /dev/null +++ b/supabase/functions/import_map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8" + } +} diff --git a/supabase/functions/report-delivery-result/index.ts b/supabase/functions/report-delivery-result/index.ts index b014404..c41d7b5 100644 --- a/supabase/functions/report-delivery-result/index.ts +++ b/supabase/functions/report-delivery-result/index.ts @@ -1,6 +1,5 @@ -import { - getOrderUpdateForDeliveryInvitationAction, -} from "../_shared/delivery-invitations.ts"; +import { getOrderUpdateForDeliveryInvitationAction } from "../_shared/delivery-invitations.ts"; +import { requireUuid } from "../_shared/security.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { @@ -42,6 +41,12 @@ Deno.serve(async (request) => { return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); } + try { + requireUuid(body.orderId, "orderId"); + } catch (e) { + return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders); + } + const supabase = createServiceClient(); await requireRateLimit(supabase, { scope: "delivery-report", diff --git a/supabase/functions/request-otp/index.ts b/supabase/functions/request-otp/index.ts index 57a793c..5f8a4b4 100644 --- a/supabase/functions/request-otp/index.ts +++ b/supabase/functions/request-otp/index.ts @@ -1,4 +1,4 @@ -import { createServiceClient } from "../_shared/chatbot.ts"; +import { createAnonClient } from "../_shared/chatbot.ts"; import { getClientIp, getCorsHeaders, @@ -38,7 +38,7 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); } - const supabase = createServiceClient(); + const supabase = createAnonClient(); const emailHash = await hashText(email); const ipHash = await hashText(getClientIp(request)); diff --git a/supabase/functions/transfer-to-logistics/index.ts b/supabase/functions/transfer-to-logistics/index.ts index 99ddc80..abc14d2 100644 --- a/supabase/functions/transfer-to-logistics/index.ts +++ b/supabase/functions/transfer-to-logistics/index.ts @@ -42,6 +42,12 @@ Deno.serve(async (request) => { return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); } + try { + requireUuid(body.orderId, "orderId"); + } catch (e) { + return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders); + } + const supabase = createServiceClient(); await requireRateLimit(supabase, { scope: "delivery-transfer", diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts index 65ac06e..9d294c2 100644 --- a/supabase/functions/verify-otp/index.ts +++ b/supabase/functions/verify-otp/index.ts @@ -1,4 +1,4 @@ -import { createServiceClient } from "../_shared/chatbot.ts"; +import { createAnonClient } from "../_shared/chatbot.ts"; import { getClientIp, getCorsHeaders, @@ -7,6 +7,7 @@ import { preflightResponse, readJsonBody, requireRateLimit, + requireSameOrigin, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 8 * 1024; @@ -28,6 +29,19 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } + const allowedOriginsForCsrf = ((): string[] => { + const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean); + const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || ""; + return [...envOrigins, appUrl].filter(Boolean); + })(); + + if (!requireSameOrigin(request, allowedOriginsForCsrf)) { + const origin = request.headers.get("origin") || ""; + if (origin) { + return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders); + } + } + try { const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { maxBytes: MAX_BODY_BYTES, @@ -43,7 +57,7 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); } - const supabase = createServiceClient(); + const supabase = createAnonClient(); const emailHash = await hashText(email); const ipHash = await hashText(getClientIp(request)); diff --git a/supabase/schema.sql b/supabase/schema.sql index 7768c99..7fd68fe 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -147,7 +147,10 @@ create table if not exists public.order_groups ( last_sms_error text, next_notification_check_at timestamptz, delivery_date date, - delivery_time text + delivery_time text, + delivery_address text, + manual_confirmation_at timestamptz, + assigned_driver_id uuid references public.users (id) ); create table if not exists public.delivery_invitations ( @@ -342,6 +345,9 @@ as $$ join public.roles r on r.id = u.role_id where u.id = auth.uid() $$; +-- Disable row-level security for this function so it can read users +-- without triggering infinite recursion via users RLS policies. +alter function public.current_role_name() set row_security = off; create or replace function public.handle_new_user() returns trigger @@ -615,16 +621,16 @@ begin to_jsonb(v_group.order_numbers) ->> 0, nullif(v_group.group_key, '') ); - v_customer_name := coalesce( - nullif(v_group.customer_name, ''), - nullif(v_group.customer ->> 'name', ''), - nullif(v_invitation.customer_name, '') - ); - v_customer_phone := coalesce( - nullif(v_group.customer_phone, ''), - nullif(v_group.customer ->> 'phone', ''), - nullif(v_invitation.customer_phone, '') - ); + v_customer_name := case + when length(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, ''))) > 0 + then left(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, '')), 1) || '.' + else null + end; + v_customer_phone := case + when length(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, ''))) >= 4 + then '+7 *** ***-' || right(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '')), 2) + else coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '')) + end; select coalesce( jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')), '[]'::jsonb @@ -964,13 +970,36 @@ using (public.current_role_name() = 'admin'); drop policy if exists "users self or admin" on public.users; create policy "users self or admin" on public.users for select -using (public.current_role_name() = 'admin' or id = auth.uid()); +using (id = auth.uid()); + +-- Helper to check admin role without RLS recursion. +create or replace function public.is_admin() +returns boolean +language sql +stable +security definer +set search_path = public +as 81937 + select exists ( + select 1 from public.users u + join public.roles r on r.id = u.role_id + where u.id = auth.uid() and r.name = 'admin' + ) +81937; +alter function public.is_admin() set row_security = off; drop policy if exists "users admin update" on public.users; create policy "users admin update" on public.users for all -using (public.current_role_name() = 'admin') -with check (public.current_role_name() = 'admin'); +using (public.is_admin()) +with check (public.is_admin()); + +drop policy if exists "users readable by logistics" on public.users; +create policy "users readable by logistics" on public.users +for select +using ( + auth.role() in ('authenticated', 'service_role') +); drop policy if exists "orders select by role" on public.orders; create policy "orders select by role" on public.orders @@ -1072,18 +1101,25 @@ with check (public.current_role_name() in ('manager', 'production_lead', 'logist drop policy if exists "order groups select by role" on public.order_groups; create policy "order groups select by role" on public.order_groups for select -using (true); +using ( + public.current_role_name() in ('manager', 'logistician', 'driver', 'admin') + or exists ( + select 1 from public.delivery_invitations di + where di.order_group_id = order_groups.id + and di.state in ('awaiting_choice', 'opened', 'reminder_sent') + ) +); drop policy if exists "order groups update coordination roles" on public.order_groups; create policy "order groups update coordination roles" on public.order_groups for update using (public.current_role_name() in ('manager', 'logistician', 'admin')) -with check (public.current_role_name() in ('manager', 'logistician', 'admin')); +with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role'); drop policy if exists "order groups insert service roles" on public.order_groups; create policy "order groups insert service roles" on public.order_groups for insert -with check (public.current_role_name() in ('manager', 'logistician', 'admin')); +with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role'); drop policy if exists "slots by order role" on public.delivery_slots; create policy "slots by order role" on public.delivery_slots @@ -1177,3 +1213,155 @@ create policy "integration events admin only" on public.integration_events for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); + +-- RPC для получения списка водителей (обход RLS) +create or replace function public.get_drivers() +returns table ( + id uuid, + email text, + name text +) +language plpgsql +security definer +set search_path = public +as $$ +begin + return query + select u.id, u.email, u.name + from public.users u + join public.roles r on r.id = u.role_id + where r.name = 'driver' + order by u.name; +end; +$$; + +revoke execute on function public.get_drivers() from anon; +grant execute on function public.get_drivers() to authenticated; + + +-- Audit log for admin actions +create table if not exists public.audit_log ( + id uuid primary key default gen_random_uuid(), + actor_id uuid references auth.users (id) on delete set null, + action text not null, + target_type text, + target_id text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default timezone('utc', now()) +); + +alter table public.audit_log enable row level security; + +create policy "audit admin only" on public.audit_log +for all +using (public.current_role_name() = 'admin') +with check (public.current_role_name() = 'admin'); + +create index if not exists idx_audit_log_actor_id on public.audit_log (actor_id); +create index if not exists idx_audit_log_action on public.audit_log (action); +create index if not exists idx_audit_log_target on public.audit_log (target_type, target_id); +create index if not exists idx_audit_log_created_at on public.audit_log (created_at desc); + +-- Trigger: log role changes +create or replace function public.log_role_change() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.audit_log (actor_id, action, target_type, target_id, metadata) + values ( + auth.uid(), + tg_op = 'INSERT' then 'role_created'::text else 'role_updated'::text end, + 'role', + new.id::text, + jsonb_build_object( + 'name', new.name, + 'permissions', new.permissions, + 'old_name', case when tg_op = 'UPDATE' then old.name else null end + ) + ); + return new; +end; +$$; + +drop trigger if exists on_role_change on public.roles; +create trigger on_role_change +after insert or update on public.roles +for each row +execute function public.log_role_change(); + +-- Trigger: log user changes +create or replace function public.log_user_change() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.audit_log (actor_id, action, target_type, target_id, metadata) + values ( + auth.uid(), + case tg_op + when 'INSERT' then 'user_created' + when 'UPDATE' then 'user_updated' + when 'DELETE' then 'user_deleted' + end, + 'user', + coalesce(new.id, old.id)::text, + jsonb_build_object( + 'email', coalesce(new.email, old.email), + 'name', coalesce(new.name, old.name), + 'role_id', coalesce(new.role_id, old.role_id) + ) + ); + return coalesce(new, old); +end; +$$; + +drop trigger if exists on_user_change on public.users; +create trigger on_user_change +after insert or update or delete on public.users +for each row +execute function public.log_user_change(); + +-- RPC for driver to update delivery status +-- Validates that the requesting user is the assigned driver +create or replace function public.update_delivery_status( + p_order_group_id uuid, + p_status text +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +declare + v_assigned_driver_id uuid; + v_current_status text; +begin + select assigned_driver_id, delivery_status + into v_assigned_driver_id, v_current_status + from public.order_groups + where id = p_order_group_id; + + if v_assigned_driver_id is null then + raise exception 'Группа не назначена водителю'; + end if; + + if v_assigned_driver_id != auth.uid() then + raise exception 'Вы не назначены на эту доставку'; + end if; + + update public.order_groups + set delivery_status = p_status, + updated_at = timezone('utc', now()) + where id = p_order_group_id; + + return true; +end; +$$; + +revoke execute on function public.update_delivery_status(uuid, text) from anon; +grant execute on function public.update_delivery_status(uuid, text) to authenticated;