From e05613ac1d1f2293ac100c22f4d7877700b6d870 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Jun 2026 12:02:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20pickup=20(=D1=81=D0=B0=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B2=D0=BE=D0=B7)=20delivery=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New status pickup in delivery workflow - DB: delivery_type, pickup_date, pickup_time_slot columns - Client page: tabs Доставка/Самовывоз with PickupSlotsPicker - PickupSlotsPicker: today/tomorrow/day-after with half-day slots - Storage notice: free 2 workdays, then 300₽/day - OrderDetailPanel: delivery type tabs, pickup date/time, status button - Edge function: delivery_type/pickup fields in confirm-delivery-choice - RPC: confirm_delivery_choice_by_token updated for pickup - orderGroupRepository: full pickup field mapping --- docker-compose.yml | 1 + index.html | 9 +- public/icons/icon-192.png | Bin 0 -> 5293 bytes public/icons/icon-512.png | Bin 0 -> 14985 bytes public/manifest.webmanifest | 20 +- public/service-worker.js | 10 +- src/components/UI/PwaInstallButton.jsx | 2 +- src/components/UI/ThemeToggle.jsx | 13 +- src/components/admin/StopWordsPanel.jsx | 81 ++- src/components/client/DeliveryChoiceFlow.jsx | 13 +- .../client/OrderCompositionPanel.jsx | 31 +- src/components/client/PickupSlotsPicker.jsx | 189 +++++++ src/components/driver/DriverShipmentPanel.jsx | 27 +- src/components/orders/OrderDetailPanel.jsx | 385 ++++++++++--- src/components/orders/OrdersTable.jsx | 51 +- src/constants/deliveryWorkflow.js | 18 +- src/context/AuthContext.jsx | 37 +- src/layouts/AppShell.jsx | 4 +- src/pages/ClientDeliveryPage.jsx | 113 +++- src/pages/DashboardPage.jsx | 18 +- src/pages/ForbiddenPage.jsx | 25 + src/pages/GroupDetailPage.jsx | 32 +- src/pages/LoginPage.jsx | 7 +- src/router.jsx | 5 + src/services/deliveryInvitationApi.js | 7 +- src/services/orderGroupViews.js | 1 + src/services/supabase/orderGroupRepository.js | 57 +- src/supabaseClient.jsx | 40 +- supabase/functions/_shared/chatbot.ts | 2 +- .../functions/_shared/delivery-invitations.ts | 16 +- supabase/functions/_shared/security.ts | 525 +++++------------- .../confirm-delivery-choice/index.ts | 52 +- supabase/functions/main/index.ts | 168 ++++++ supabase/functions/request-otp/index.ts | 62 ++- supabase/functions/verify-otp/index.ts | 132 ++++- volumes/functions/README.md | 83 +++ volumes/functions/_shared/chatbot.ts | 72 +++ .../_shared/delivery-invitations.test.ts | 84 +++ .../functions/_shared/delivery-invitations.ts | 313 +++++++++++ .../functions/_shared/integration-events.ts | 30 + volumes/functions/_shared/security.ts | 172 ++++++ volumes/functions/_shared/workflow.test.ts | 35 ++ volumes/functions/_shared/workflow.ts | 44 ++ volumes/functions/chatbot-webhook/index.ts | 141 +++++ .../confirm-delivery-choice/index.ts | 360 ++++++++++++ .../create-delivery-invitation/index.ts | 409 ++++++++++++++ .../get-delivery-invitation/index.ts | 191 +++++++ volumes/functions/import_map.json | 5 + volumes/functions/main/index.ts | 168 ++++++ .../functions/report-delivery-result/index.ts | 158 ++++++ volumes/functions/request-otp/index.ts | 126 +++++ .../functions/send-chatbot-message/index.ts | 152 +++++ .../functions/transfer-to-logistics/index.ts | 156 ++++++ .../index.ts | 230 ++++++++ volumes/functions/verify-otp/index.ts | 190 +++++++ 55 files changed, 4659 insertions(+), 613 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 src/components/client/PickupSlotsPicker.jsx create mode 100644 src/pages/ForbiddenPage.jsx create mode 100644 supabase/functions/main/index.ts create mode 100644 volumes/functions/README.md create mode 100644 volumes/functions/_shared/chatbot.ts create mode 100644 volumes/functions/_shared/delivery-invitations.test.ts create mode 100644 volumes/functions/_shared/delivery-invitations.ts create mode 100644 volumes/functions/_shared/integration-events.ts create mode 100644 volumes/functions/_shared/security.ts create mode 100644 volumes/functions/_shared/workflow.test.ts create mode 100644 volumes/functions/_shared/workflow.ts create mode 100644 volumes/functions/chatbot-webhook/index.ts create mode 100644 volumes/functions/confirm-delivery-choice/index.ts create mode 100644 volumes/functions/create-delivery-invitation/index.ts create mode 100644 volumes/functions/get-delivery-invitation/index.ts create mode 100644 volumes/functions/import_map.json create mode 100644 volumes/functions/main/index.ts create mode 100644 volumes/functions/report-delivery-result/index.ts create mode 100644 volumes/functions/request-otp/index.ts create mode 100644 volumes/functions/send-chatbot-message/index.ts create mode 100644 volumes/functions/transfer-to-logistics/index.ts create mode 100644 volumes/functions/update-order-group-delivery-choice/index.ts create mode 100644 volumes/functions/verify-otp/index.ts diff --git a/docker-compose.yml b/docker-compose.yml index ac5b179..2817b28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -455,6 +455,7 @@ services: SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + APP_ALLOWED_ORIGINS: https://dost.supersamsev.ru,https://supa.supersamsev.ru,https://supasevdev.mkn8n.ru # TODO: Allow configuring VERIFY_JWT per function. VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" command: diff --git a/index.html b/index.html index 6ac6a54..9f051eb 100644 --- a/index.html +++ b/index.html @@ -6,14 +6,15 @@ + - + + - Construction Delivery Control - + SuperSam Доставка
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..53158b05a6349b0657a6ec59602f51573d973641 GIT binary patch literal 5293 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 N(A6^1Y*2^B{6Du%-a7yQ literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..c23bc1d9c3c765ee5a31dc727024d79e71d7b622 GIT binary patch literal 14985 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 literal 0 HcmV?d00001 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index c722e80..2a803ec 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -10,16 +10,28 @@ "lang": "ru", "icons": [ { - "src": "/icons/icon-192.svg", + "src": "/icons/icon-192.png", "sizes": "192x192", - "type": "image/svg+xml", + "type": "image/png", "purpose": "any" }, { - "src": "/icons/icon-512.svg", + "src": "/icons/icon-512.png", "sizes": "512x512", - "type": "image/svg+xml", + "type": "image/png", "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/public/service-worker.js b/public/service-worker.js index 6e0b352..37d2f3d 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,9 +1,9 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v4"; - const RUNTIME_CACHE = "construction-delivery-runtime-v4"; - const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; + const STATIC_CACHE = "construction-delivery-static-v5"; + const RUNTIME_CACHE = "construction-delivery-runtime-v5"; + const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { event.waitUntil( @@ -93,8 +93,8 @@ self.addEventListener("push", (event) => { const title = data.title || "Уведомление"; const options = { body: data.body || "", - icon: data.icon || "/icons/icon-192.svg", - badge: data.badge || "/icons/icon-192.svg", + icon: data.icon || "/icons/icon-192.png", + badge: data.badge || "/icons/icon-192.png", data: data.data || {}, tag: data.tag || "default", vibrate: [100, 50, 100], diff --git a/src/components/UI/PwaInstallButton.jsx b/src/components/UI/PwaInstallButton.jsx index 319cdcb..b1c08f2 100644 --- a/src/components/UI/PwaInstallButton.jsx +++ b/src/components/UI/PwaInstallButton.jsx @@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable }) {showTip && ( -
+
{isIOS ? ( <>

Установка на iOS

diff --git a/src/components/UI/ThemeToggle.jsx b/src/components/UI/ThemeToggle.jsx index 67f3d03..cac78c1 100644 --- a/src/components/UI/ThemeToggle.jsx +++ b/src/components/UI/ThemeToggle.jsx @@ -1,13 +1,18 @@ import React from "react"; -import { Button } from "./Button"; import { useTheme } from "../../context/ThemeContext"; export const ThemeToggle = () => { const { theme, toggleTheme } = useTheme(); return ( - + ); }; diff --git a/src/components/admin/StopWordsPanel.jsx b/src/components/admin/StopWordsPanel.jsx index a0b702c..9ffd338 100644 --- a/src/components/admin/StopWordsPanel.jsx +++ b/src/components/admin/StopWordsPanel.jsx @@ -5,10 +5,12 @@ import { supabase } from "../../supabaseClient"; export const StopWordsPanel = () => { const [words, setWords] = React.useState([]); + const [scope, setScope] = React.useState("everywhere"); const [newWord, setNewWord] = React.useState(""); const [isLoading, setIsLoading] = React.useState(true); + const [isSavingScope, setIsSavingScope] = React.useState(false); const [error, setError] = React.useState(""); - const [deletingId, setDeletingId] = React.useState(null); + const [savingId, setSavingId] = React.useState(null); const loadWords = React.useCallback(async () => { setIsLoading(true); @@ -25,7 +27,18 @@ export const StopWordsPanel = () => { setIsLoading(false); }, []); - React.useEffect(() => { loadWords(); }, [loadWords]); + const loadScope = React.useCallback(async () => { + const { data } = await supabase + .from("stop_words_scope") + .select("scope") + .eq("id", 1) + .single(); + if (data) setScope(data.scope); + }, []); + + React.useEffect(() => { + Promise.all([loadWords(), loadScope()]); + }, [loadWords, loadScope]); const handleAdd = async () => { const trimmed = newWord.trim().toLowerCase(); @@ -47,7 +60,7 @@ export const StopWordsPanel = () => { }; const handleDelete = async (id) => { - setDeletingId(id); + setSavingId(id); const { error: deleteError } = await supabase .from("stop_words") .delete() @@ -57,7 +70,21 @@ export const StopWordsPanel = () => { } else { await loadWords(); } - setDeletingId(null); + setSavingId(null); + }; + + const handleScopeChange = async (newScope) => { + setIsSavingScope(true); + setError(""); + const { error: upsertError } = await supabase + .from("stop_words_scope") + .upsert({ id: 1, scope: newScope }, { onConflict: "id" }); + if (upsertError) { + setError(upsertError.message); + } else { + setScope(newScope); + } + setIsSavingScope(false); }; const handleKeyDown = (e) => { @@ -72,8 +99,46 @@ export const StopWordsPanel = () => {

Стоп-слова

- Позиции с этими словами не показываются клиентам в карточке доставки. - Добавляйте слова-маркеры: «сверление», «обмер» и т.д. + Позиции с этими словами скрываются из состава заказа. +

+
+ +
+

Где применять стоп-слова:

+
+ + +
+

+ {scope === "everywhere" + ? "Стоп-слова скрывают позиции и в панели управления, и в карточке клиента." + : "Стоп-слова скрывают позиции только на странице выбора времени доставки."}

@@ -110,12 +175,12 @@ export const StopWordsPanel = () => { {w.word} ))} diff --git a/src/components/client/DeliveryChoiceFlow.jsx b/src/components/client/DeliveryChoiceFlow.jsx index 56cbaba..e3b7ce2 100644 --- a/src/components/client/DeliveryChoiceFlow.jsx +++ b/src/components/client/DeliveryChoiceFlow.jsx @@ -22,6 +22,7 @@ export const DeliveryChoiceFlow = ({ invitation = {}, selectedSlot = null, onConfirmChoice = () => {}, + deliveryType = "delivery", }) => { const state = invitation.state || "awaiting_choice"; const isActive = ACTIVE_STATES.has(state); @@ -36,16 +37,22 @@ export const DeliveryChoiceFlow = ({ ); } + const typeLabel = deliveryType === "pickup" ? "самовывоз" : "доставку"; + return (
-

Согласование доставки

+

+ {deliveryType === "pickup" ? "Согласование самовывоза" : "Согласование доставки"} +

-

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

+

+ {deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"} +

{STATE_LABELS[state]}

- {invitationReference}. Выберите удобную половину дня. + {invitationReference}. Выберите удобную половину дня для {typeLabel}.

diff --git a/src/components/client/OrderCompositionPanel.jsx b/src/components/client/OrderCompositionPanel.jsx index 8bdedac..1718063 100644 --- a/src/components/client/OrderCompositionPanel.jsx +++ b/src/components/client/OrderCompositionPanel.jsx @@ -2,6 +2,7 @@ import React from "react"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { getInvitationReferenceLabel } from "./invitationReference"; +import { supabase } from "../../supabaseClient"; const flattenOrderProducts = (rawItems) => { if (!Array.isArray(rawItems) || rawItems.length === 0) return []; @@ -30,6 +31,7 @@ const flattenOrderProducts = (rawItems) => { name: pName, quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), unit: String(p.product_ed || p.unit || "").trim(), + _sourceOrder: sub, }); } } @@ -42,6 +44,7 @@ const flattenOrderProducts = (rawItems) => { name: pName, quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), unit: String(p.product_ed || p.unit || "").trim(), + _sourceOrder: item, }); } } @@ -54,6 +57,7 @@ const flattenOrderProducts = (rawItems) => { name, quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(), unit: String(item.product_ed || item.unit || "").trim(), + _sourceOrder: item, }); } @@ -67,10 +71,26 @@ const matchesStopWord = (name, stopWords) => { }; export const OrderCompositionPanel = ({ invitation = {} }) => { - const stopWords = invitation.stopWords || []; + const [stopWords, setStopWords] = React.useState([]); + const [stopWordsLoaded, setStopWordsLoaded] = React.useState(false); + const [scopeActive, setScopeActive] = React.useState(true); + + React.useEffect(() => { + if (!supabase) { setStopWordsLoaded(true); return; } + Promise.all([ + supabase.from("stop_words").select("word"), + supabase.from("stop_words_scope").select("scope").eq("id", 1).single(), + ]).then(([{ data: wordsData }, { data: scopeData }]) => { + if (wordsData) setStopWords(wordsData.map((d) => d.word)); + setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only"); + setStopWordsLoaded(true); + }) + .catch(() => setStopWordsLoaded(true)); + }, []); + const rawItems = invitation.orderItems || invitation.items || []; const allProducts = flattenOrderProducts(rawItems); - const products = stopWords.length + const products = (stopWords.length && scopeActive) ? allProducts.filter((p) => !matchesStopWord(p.name, stopWords)) : allProducts; @@ -79,6 +99,8 @@ export const OrderCompositionPanel = ({ invitation = {} }) => { const [isExpanded, setIsExpanded] = React.useState(false); + // Hide the entire panel if there are no products to show and some were filtered + if (products.length === 0 && filteredCount > 0) return null; if (products.length === 0 && filteredCount === 0) return null; return ( @@ -118,11 +140,6 @@ export const OrderCompositionPanel = ({ invitation = {} }) => { ) : null}
))} - {products.length === 0 && filteredCount > 0 && ( -

- Все позиции исключены из отображения. -

- )}
)} diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx new file mode 100644 index 0000000..efcb16a --- /dev/null +++ b/src/components/client/PickupSlotsPicker.jsx @@ -0,0 +1,189 @@ +import React from "react"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; +import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting"; + +const DELIVERY_TIMEZONE = "Europe/Simferopol"; + +const getCrimeaTodayKey = (referenceDate = new Date()) => { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: DELIVERY_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(referenceDate); + const year = parts.find((p) => p.type === "year")?.value || ""; + const month = parts.find((p) => p.type === "month")?.value || ""; + const day = parts.find((p) => p.type === "day")?.value || ""; + return `${year}-${month}-${day}`; +}; + +const addDaysKey = (dateKey, amount) => { + const base = new Date(`${dateKey}T12:00:00Z`); + if (Number.isNaN(base.getTime())) return ""; + base.setUTCDate(base.getUTCDate() + amount); + return base.toISOString().slice(0, 10); +}; + +const getCrimeaHour = (referenceDate = new Date()) => { + return parseInt( + new Intl.DateTimeFormat("ru-RU", { + timeZone: DELIVERY_TIMEZONE, + hour: "numeric", + hour12: false, + }).format(referenceDate), + 10 + ); +}; + +const isWeekend = (dateKey) => { + const d = new Date(`${dateKey}T12:00:00Z`); + const day = d.getUTCDay(); + return day === 0 || day === 6; +}; + +const getNextWorkday = (dateKey) => { + let next = addDaysKey(dateKey, 1); + while (isWeekend(next)) { + next = addDaysKey(next, 1); + } + return next; +}; + +const getPickupSlots = (referenceDate = new Date()) => { + const todayKey = getCrimeaTodayKey(referenceDate); + const hour = getCrimeaHour(referenceDate); + const isTodayWorkday = !isWeekend(todayKey); + + const slots = []; + + if (isTodayWorkday && hour < 12) { + slots.push({ + id: `pickup-${todayKey}-first`, + date: todayKey, + time: "Первая половина дня", + label: "Сегодня", + pickupType: "today", + }); + } + + const tomorrow = addDaysKey(todayKey, 1); + const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey); + slots.push({ + id: `pickup-${tomorrowWorkday}-first`, + date: tomorrowWorkday, + time: "Первая половина дня", + label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра", + pickupType: "tomorrow", + }); + slots.push({ + id: `pickup-${tomorrowWorkday}-second`, + date: tomorrowWorkday, + time: "Вторая половина дня", + label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра", + pickupType: "tomorrow", + }); + + const dayAfter = addDaysKey(tomorrowWorkday, 1); + const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter); + slots.push({ + id: `pickup-${dayAfterWorkday}-first`, + date: dayAfterWorkday, + time: "Первая половина дня", + label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра", + pickupType: "dayAfter", + }); + slots.push({ + id: `pickup-${dayAfterWorkday}-second`, + date: dayAfterWorkday, + time: "Вторая половина дня", + label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра", + pickupType: "dayAfter", + }); + + return slots; +}; + +const FREE_STORAGE_NOTICE = ( +
+

ℹ️ Условия хранения

+

+ Бесплатное хранение — 2 рабочих дня с даты готовности. +

+

+ Начиная с 3-го рабочего дня — 300 ₽/день платного хранения. +

+
+); + +export const PickupSlotsPicker = ({ + onSelectSlot, + selectedSlotId, + referenceDate = new Date(), +}) => { + const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]); + + if (!slots.length) { + return ( + +

+ Нет доступных слотов для самовывоза. +

+
+ ); + } + + const grouped = React.useMemo(() => { + const map = new Map(); + for (const slot of slots) { + if (!map.has(slot.date)) map.set(slot.date, []); + map.get(slot.date).push(slot); + } + return Array.from(map.entries()); + }, [slots]); + + return ( +
+ {grouped.map(([date, dateSlots]) => ( +
+ +
+

+ Самовывоз{" "} + {dateSlots[0]?.label + ? `${dateSlots[0].label.charAt(0).toLowerCase()}${dateSlots[0].label.slice(1)}` + : ""}{" "} + · {formatDeliveryDate(date)} +

+ Раскрыть + Свернуть +
+
+
+
+ {dateSlots.map((slot) => { + const isSelected = selectedSlotId === slot.id; + return ( + + ); + })} +
+
+
+ ))} + {FREE_STORAGE_NOTICE} +
+ ); +}; diff --git a/src/components/driver/DriverShipmentPanel.jsx b/src/components/driver/DriverShipmentPanel.jsx index 598b2d6..d7bf5e9 100644 --- a/src/components/driver/DriverShipmentPanel.jsx +++ b/src/components/driver/DriverShipmentPanel.jsx @@ -1,8 +1,15 @@ import React from "react"; +import { supabase } from "../../supabaseClient"; import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; +const matchesStopWord = (name, stopWords) => { + if (!stopWords || !stopWords.length) return false; + const lower = name.toLowerCase(); + return stopWords.some((sw) => lower.includes(sw.toLowerCase())); +}; + const parseOrderItems = (order) => { if (!order) return []; @@ -88,7 +95,25 @@ const parseOrderItems = (order) => { }; export const DriverShipmentPanel = ({ order, onShipmentChange }) => { - const items = React.useMemo(() => parseOrderItems(order), [order]); + const [stopWords, setStopWords] = React.useState([]); + const [scopeActive, setScopeActive] = React.useState(true); + + React.useEffect(() => { + if (!supabase) return; + Promise.all([ + supabase.from("stop_words").select("word"), + supabase.from("stop_words_scope").select("scope").eq("id", 1).single(), + ]).then(([{ data: wordsData }, { data: scopeData }]) => { + if (wordsData) setStopWords(wordsData.map((d) => d.word)); + setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only"); + }).catch(() => {}); + }, []); + + const allItems = React.useMemo(() => parseOrderItems(order), [order]); + const items = React.useMemo(() => { + if (!stopWords.length || !scopeActive) return allItems; + return allItems.filter((item) => !matchesStopWord(item.name, stopWords)); + }, [allItems, stopWords, scopeActive]); const [shippedItems, setShippedItems] = React.useState(new Set()); const [comments, setComments] = React.useState({}); const [commentInput, setCommentInput] = React.useState(""); diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index e1d3e9c..543e76e 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -1,3 +1,43 @@ + +const DriverShipmentReport = ({ shipmentData }) => { + if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null; + + return ( + +
+ + + + Проблемы с доставкой позиций +
+

+ Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное — доставлено. +

+
+ {shipmentData.map((item) => ( +
+
+ {item.name} + {item.quantity || item.unit ? ( + {[item.quantity, item.unit].filter(Boolean).join(" ")} + ) : null} +
+ {item.comment ? ( +

Причина: {item.comment}

+ ) : ( +

Причина не указана

+ )} +
+ ))} +
+ +
+ ); +}; + import React from "react"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; @@ -5,6 +45,7 @@ import { Button } from "../UI/Button"; import { Select } from "../UI/Select"; import { Panel } from "../UI/Panel"; import { DriverShipmentPanel } from "../driver/DriverShipmentPanel"; +import { supabase } from "../../supabaseClient"; import { getOrderGroupDeliveryStatusLabel, getOrderGroupDisplayStatusLabel, @@ -39,6 +80,18 @@ const renderList = (values) => { const renderValue = (value) => value || "Нет данных"; +const normalizeNom = (nom) => { + if (!nom) return ''; + // 1C escapes backslashes: "СФ Т\\ЕА-33584" → normalize for comparison + return String(nom).replace(/\\\\/g, '\\').trim(); +}; + +const getAllBillNumbers = (order) => { + const orders = parseOrderList(order); + if (!orders.length) return order.orderNumbers || []; + return orders.map((o) => o.nom || o.name || '').filter(Boolean); +}; + const parseOrderList = (order) => { if (!order) return []; @@ -61,18 +114,24 @@ const parseOrderList = (order) => { } // Fallback: sourceOrders (1C exchange data) - // Collect orderList from ALL source orders, not just the first one + // 1C sends the FULL order composition (main + associated bills) in EVERY source order's orderList. + // We must deduplicate by nom to avoid showing the same items multiple times. if (order.sourceOrders) { let parsed = order.sourceOrders; if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { /* ignore */ } } if (Array.isArray(parsed) && parsed.length > 0) { + const seen = new Set(); const allItems = []; for (const src of parsed) { if (src && Array.isArray(src.orderList)) { for (const ol of src.orderList) { if (ol && (ol.items || ol.nom || ol.name)) { + const normalizedNom = normalizeNom(ol.nom || ol.name || ''); + // Deduplicate by nom — 1C repeats same orderList in every source order + if (seen.has(normalizedNom)) continue; + seen.add(normalizedNom); allItems.push(ol); } } @@ -246,10 +305,41 @@ const normalizeDateForInput = (value) => { return ""; }; +const matchesStopWord = (name, stopWords) => { + if (!stopWords || !stopWords.length) return false; + const lower = name.toLowerCase(); + return stopWords.some((sw) => lower.includes(sw.toLowerCase())); +}; + +const useStopWords = () => { + const [stopWords, setStopWords] = React.useState([]); + const [active, setActive] = React.useState(true); + React.useEffect(() => { + if (!supabase) return; + Promise.all([ + supabase.from("stop_words").select("word").then(r => r.data || []), + supabase.from("stop_words_scope").select("scope").eq("id", 1).single().then(r => r.data), + ]).then(([words, scopeRow]) => { + setStopWords(words.map((d) => d.word)); + setActive(scopeRow?.scope !== "client_only"); + }); + }, []); + return { stopWords, active }; +}; + const CollapsibleOrderComposition = ({ order }) => { const [isExpanded, setIsExpanded] = React.useState(false); + const { stopWords, active } = useStopWords(); + const orders = parseOrderList(order); - const totalPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0); + const allPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0); + const filteredPositions = active ? orders.reduce((sum, o) => { + if (!o.items) return sum; + return sum + o.items.filter((item) => { + const name = String(item.product_name || item.name || item.title || ""); + return !matchesStopWord(name, stopWords); + }).length; + }, 0) : allPositions; return (
@@ -260,7 +350,11 @@ const CollapsibleOrderComposition = ({ order }) => { > Состав заказа - {totalPositions > 0 ? `${totalPositions} поз.` : ''} +{active && filteredPositions < allPositions + ? `${filteredPositions} поз. из ${allPositions}` + : filteredPositions > 0 + ? `${filteredPositions} поз.` + : ''} {

{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}

- {orderItem.items && orderItem.items.length > 0 ? ( -
- {orderItem.items.map((item, itemIdx) => ( -
- {item.product_name || item.name || item.title || ''} - - {item.product_quantity || item.quantity || item.count || item.amount || ""} {item.product_ed || item.unit || ""} - -
- ))} -
- ) : ( -

Позиции не указаны

- )} + {(() => { + const filtered = (orderItem.items || []).filter((item) => { + const name = String(item.product_name || item.name || item.title || ""); + return active ? !matchesStopWord(name, stopWords) : true; + }); + if (filtered.length === 0 && active && (orderItem.items || []).length > 0) { + return

Только услуги — скрыты стоп-словами

; + } + if (filtered.length === 0) { + return

Позиции не указаны

; + } + return ( +
+ {filtered.map((item, itemIdx) => ( +
+ {item.product_name || item.name || item.title || ''} + + {item.product_quantity || item.quantity || item.count || item.amount || ""} {item.product_ed || item.unit || ""} + +
+ ))} +
+ ); + })()}
)) )} @@ -442,6 +546,7 @@ export const OrderDetailPanel = ({ userRole, }) => { const [problemReason, setProblemReason] = React.useState(null); + const [pendingStatus, setPendingStatus] = React.useState(null); const [deliveryDate, setDeliveryDate] = React.useState(""); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [formMessage, setFormMessage] = React.useState(""); @@ -449,6 +554,9 @@ export const OrderDetailPanel = ({ const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [driverMessage, setDriverMessage] = React.useState(""); const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); + const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery"); + const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || ""); + const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const [currentMonth, setCurrentMonth] = React.useState(() => { const existingDeliveryDate = fromDateKey(order?.deliveryDate); @@ -483,8 +591,11 @@ export const OrderDetailPanel = ({ const selectedDate = fromDateKey(selectedDateKey) || new Date(); setCurrentMonth(startOfMonth(selectedDate)); setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay)); + setDeliveryType(order?.deliveryType || "delivery"); + setPickupDate(order?.pickupDate || ""); + setPickupTimeSlot(normalizeDeliveryTimeChoice(order?.pickupTimeSlot || order?.deliveryTime || order?.deliveryHalfDay)); setFormMessage(""); - }, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime]); + }, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime, order?.deliveryType, order?.pickupDate, order?.pickupTimeSlot]); if (!order) { return ( @@ -507,21 +618,25 @@ export const OrderDetailPanel = ({ }, []); const handleSaveDeliveryChoice = async () => { - if (!deliveryDate || !deliveryTime) { - setFormMessage("Укажите дату и половину дня доставки."); + const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate; + const effectiveTime = deliveryType === "pickup" ? pickupTimeSlot : deliveryTime; + if (!effectiveDate || !effectiveTime) { + setFormMessage(deliveryType === "pickup" ? "Укажите дату и время самовывоза." : "Укажите дату и половину дня доставки."); return; } - if (!isFutureDeliveryDate(deliveryDate)) { - setFormMessage("Выберите дату доставки позже сегодняшнего дня."); + if (!isFutureDeliveryDate(effectiveDate)) { + setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня."); return; } try { const result = await onSaveManualDeliveryChoice?.({ orderGroupId: order.id, - deliveryDate, - deliveryTime, + deliveryDate: deliveryType === "pickup" ? pickupDate : deliveryDate, + deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime, + deliveryType, + ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), }); if (result?.success) { @@ -577,7 +692,7 @@ export const OrderDetailPanel = ({ {getOrderGroupDisplayStatusLabel(order)} -
+

Дата доставки @@ -590,6 +705,12 @@ export const OrderDetailPanel = ({

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

+
+

+ Тип доставки +

+

{order.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}

+

Водитель @@ -661,6 +782,38 @@ export const OrderDetailPanel = ({ : "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}

+ {/* Delivery type tabs */} +
+ + +
+ {deliveryType === "pickup" && ( +
+

ℹ️ Условия хранения

+

Бесплатное хранение — 2 рабочих дня с даты готовности.

+

Начиная с 3-го рабочего дня — 300 ₽/день платного хранения.

+
+ )} {isDeliveryAgreed && !isEditingDate ? (
@@ -688,6 +841,7 @@ export const OrderDetailPanel = ({ ) : null}
) : ( +{deliveryType === "delivery" ? (
+ ) : ( +
+ + {isCalendarOpen ? ( +
+
+
+

Календарь самовывоза

+

{monthLabel}

+
+
+ + +
+
+
+ {WEEK_DAY_LABELS.map((day) => (
{day}
))} +
+
+ {calendarDays.map((day, index) => { + if (!day) return
; + const dateKey = toDateKey(day); + const isWeekend = isWeekendDate(day); + const isSelectable = isSelectableCalendarDate(day, minSelectableDateKey); + const isSelected = dateKey === pickupDate; + const isDisabled = !isSelectable; + const dayNumber = String(day.getDate()).padStart(2, "0"); + return ( + + ); + })} +
+

Выходные отмечены пунктиром и недоступны.

+
+ ) : null} +
+ {DELIVERY_TIME_OPTIONS.map((option) => ( + + ))} +
+
+ )} -
)} {formMessage ? (

{formMessage}

@@ -916,6 +1125,7 @@ export const OrderDetailPanel = ({ { value: "loaded", label: "Загружено", manual: true }, { value: "on_route", label: "В пути", manual: true }, { value: "delivered", label: "Доставлено", manual: true }, + { value: "pickup", label: "Самовывоз", manual: true }, { value: "problem", label: "Проблема", manual: true }, { value: "cancelled", label: "Отменено", manual: true }, ].map((statusOption) => { @@ -975,24 +1185,14 @@ export const OrderDetailPanel = ({
Статус доставки

- Обновите статус по мере выполнения доставки. + Выберите статус и нажмите «Сохранить».

{problemReason !== null ? ( { - onChangeDeliveryStatus({ - orderGroupId: order.id, - status: "problem", - details: { reason: reasonValue, reasonLabel }, - }).then((response) => { - if (!response.success) { - setFormMessage(response.error || "Не удалось обновить статус"); - } else { - setFormMessage("Статус обновлён: проблема — " + reasonLabel); - } - setProblemReason(null); - }); + setPendingStatus({ value: "problem", reason: reasonValue, reasonLabel }); + setProblemReason(null); }} onCancel={() => setProblemReason(null)} /> @@ -1003,54 +1203,76 @@ export const OrderDetailPanel = ({ const IN_TRANSIT_STATUSES = ["loaded", "on_route"]; const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus); - let availableButtons = []; - if (currentStatus === "driver_assigned") { - availableButtons = [ - { value: "loaded", label: "Загружено" }, - { value: "problem", label: "Проблема" }, - ]; - } else if (isOnRoute) { - availableButtons = [ - { value: "delivered", label: "Доставлено" }, - { value: "problem", label: "Проблема" }, - ]; - } else if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") { - availableButtons = []; + let statusOptions = []; + if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") { + statusOptions = []; } else { - availableButtons = [ - { value: "loaded", label: "Загружено" }, + statusOptions = [ { value: "delivered", label: "Доставлено" }, { value: "problem", label: "Проблема" }, ]; } + + if (statusOptions.length === 0) return null; - return availableButtons.map((statusOption) => ( - - )); + setPendingStatus({ value: statusOption.value }); + }} + > + {statusOption.label} + {isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered ? ( + + ({shipmentState.shipped}/{shipmentState.total}) + + ) : null} + + ); + }); })()}
+ {pendingStatus ? ( +
+ + +
+ ) : null} {formMessage ? (

{formMessage}

) : null} @@ -1058,13 +1280,16 @@ export const OrderDetailPanel = ({ ) : null} - Номера заказов - {renderList(order.orderNumbers)} + Счета + {renderList(getAllBillNumbers(order))} + {userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? ( + + ) : null} {userRole !== "driver" ? ( Дополнительные данные diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index a060f24..3edecdd 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -7,6 +7,8 @@ import { getOrderGroupStatusTone, } from "../../services/orderGroupViews"; +const MAX_VISIBLE_INVOICES = 2; + const buildGroupSummary = (group) => { const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`; const parts = [orderCountLabel]; @@ -22,11 +24,36 @@ const buildGroupSummary = (group) => { }; const renderOrderNumbers = (group) => { - if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) { + const numbers = group.allBillNumbers || group.orderNumbers; + if (!Array.isArray(numbers) || !numbers.length) { return "Номера не указаны"; } - return group.orderNumbers.slice(0, 3).join(" · "); + if (numbers.length <= MAX_VISIBLE_INVOICES) { + return numbers.join(", "); + } + const visible = numbers.slice(0, MAX_VISIBLE_INVOICES); + const remaining = numbers.length - MAX_VISIBLE_INVOICES; + return `${visible.join(", ")} +${remaining}`; +}; + +const renderMobileOrderNumbers = (group) => { + const numbers = group.allBillNumbers || group.orderNumbers; + if (!Array.isArray(numbers) || !numbers.length) { + return "Номера не указаны"; + } + + if (numbers.length <= MAX_VISIBLE_INVOICES) { + return numbers.join(", "); + } + const visible = numbers.slice(0, MAX_VISIBLE_INVOICES); + const remaining = numbers.length - MAX_VISIBLE_INVOICES; + return ( + <> + {visible.join(", ")} + +{remaining} + + ); }; export const OrdersTable = ({ @@ -87,7 +114,7 @@ export const OrdersTable = ({
{buildGroupSummary(group)}
-
{renderOrderNumbers(group)}
+
{renderMobileOrderNumbers(group)}
{formatDateTime(group.updatedAt)}
@@ -104,9 +131,8 @@ export const OrdersTable = ({ - - - + + @@ -125,15 +151,12 @@ export const OrdersTable = ({ > - -
ГруппаКлиентНомераГруппа / КлиентСчёта Статус Водитель Дата доставки
{group.displayTitle || group.customerName || group.groupKey}
-
{group.groupKey}
-
-
{group.customerName}
-
- {group.customerPhone} · {group.customerDate} +
+ {[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
+
{group.groupKey}
+ {renderOrderNumbers(group)} @@ -160,4 +183,4 @@ export const OrdersTable = ({ ); -}; +}; \ No newline at end of file diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js index 1ad1ad7..8fcf233 100644 --- a/src/constants/deliveryWorkflow.js +++ b/src/constants/deliveryWorkflow.js @@ -100,6 +100,15 @@ export const ORDER_STATUS_META = { criticalAfterHours: 24, tone: "accent", }, + "Самовывоз": { + comment: "Клиент выбрал самовывоз. Заказ ожидает выдачи на складе.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 24, + criticalAfterHours: 48, + tone: "accent", + }, "Передан логисту": { comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.", ownerRole: "logistician", @@ -219,8 +228,8 @@ export const ORDER_STATUS_TRANSITIONS = { "В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"], "Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"], "Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"], - "Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"], - "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"], + "Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Проблема доставки", "Отменён"], + "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз"], "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], "Назначен водитель": ["Загружен", "Проблема доставки"], Загружен: ["Доставлен", "Проблема доставки"], @@ -228,12 +237,13 @@ export const ORDER_STATUS_TRANSITIONS = { Доставлен: ["Закрыт"], "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], "Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"], + "Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"], Закрыт: [], Отменён: [], }; export const ROLE_TRANSITION_TARGETS = { - manager: ORDER_STATUSES, + manager: [...ORDER_STATUSES], production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"], logistician: [ "Новый", @@ -243,6 +253,7 @@ export const ROLE_TRANSITION_TARGETS = { "Доставка согласована", "Передан логисту", "Назначен водитель", + "Самовывоз", "Проблема доставки", "Платное хранение", "Закрыт", @@ -264,6 +275,7 @@ export const LOGISTICS_STATUSES = [ "Ожидает согласования доставки", "Доставка согласована", "Назначен водитель", + "Самовывоз", "Проблема доставки", ]; diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 51d2e6a..9292400 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -126,9 +126,9 @@ const isSignedOut = () => sessionStorage.getItem(SIGNED_OUT_FLAG) === "1"; /** Clear ALL auth state from storage — called on explicit signOut */ const clearAllAuthStorage = () => { - // Clear Supabase secureStorage keys from sessionStorage - sessionStorage.removeItem("supersam-auth"); - sessionStorage.removeItem("supersam-ak"); + // Clear Supabase secureStorage keys from localStorage + localStorage.removeItem("supersam-auth"); + localStorage.removeItem("supersam-ak"); // Clear local auth cache from localStorage localStorage.removeItem(STORAGE_KEY); localStorage.removeItem("construction-auth-role-hint"); @@ -148,6 +148,8 @@ export const AuthProvider = ({ children }) => { const [isOtpSent, setIsOtpSent] = useState(false); const [isLoading, setIsLoading] = useState(false); const [authError, setAuthError] = useState(""); + // Track whether the initial session restore from Supabase has completed + const [isSessionLoading, setIsSessionLoading] = useState(() => !!(hasSupabaseConfig && supabase)); // Ref to prevent getSession from restoring session after explicit signOut const signedOutRef = useRef(false); @@ -157,18 +159,31 @@ export const AuthProvider = ({ children }) => { return undefined; } + // Track whether getSession() has resolved — onAuthStateChange's INITIAL_SESSION + // can fire with null before storage has been read, causing premature redirect. + // Only onAuthStateChange should update user AFTER initial load is complete. + let getSessionResolved = false; + const { data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { + } = supabase.auth.onAuthStateChange((event, session) => { + // During initial load, ignore null sessions from onAuthStateChange — + // getSession() is the authoritative source. SIGNED_OUT events are always valid. if (!session?.user) { + if (!getSessionResolved && event === "INITIAL_SESSION") { + // Don't set user=null or isSessionLoading=false yet — let getSession() decide. + return; + } setUser(null); setAuthError(""); window.__supersam_user_id__ = null; + setIsSessionLoading(false); return; } // Block session restore if user explicitly signed out (ref or sessionStorage flag) if (signedOutRef.current || isSignedOut()) { + setIsSessionLoading(false); return; } @@ -182,24 +197,29 @@ export const AuthProvider = ({ children }) => { } else { setUser({ ...baseUser, role: baseUser.role || "manager" }); } + setIsSessionLoading(false); }); } else { setUser(null); + setIsSessionLoading(false); } setAuthError(""); }); supabase.auth.getSession().then(({ data, error }) => { + getSessionResolved = true; if (error && isStaleRefreshTokenError(error)) { setUser(null); setAuthError("Сессия истекла. Войдите заново."); clearAllAuthStorage(); void supabase.auth.signOut({ scope: "local" }); + setIsSessionLoading(false); return; } // Block session restore if user explicitly signed out (ref or sessionStorage flag) if (signedOutRef.current || isSignedOut()) { + setIsSessionLoading(false); return; } @@ -212,9 +232,17 @@ export const AuthProvider = ({ children }) => { } else { setUser({ ...baseUser, role: baseUser.role || "manager" }); } + setIsSessionLoading(false); }); + } else { + setIsSessionLoading(false); } + } else { + setIsSessionLoading(false); } + }).catch(() => { + // getSession rejected — ensure we don't hang forever + setIsSessionLoading(false); }); return () => subscription.unsubscribe(); @@ -366,6 +394,7 @@ export const AuthProvider = ({ children }) => { pendingEmail, isOtpSent, isLoading, + isSessionLoading, authError, isDemoMode, requestOtp, diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 3eba546..4129cd8 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -102,7 +102,7 @@ export const AppShell = ({ {user.name} · {ROLE_LABELS[user.role] || user.role}

-
+
{onOpenGuide ? ( ) : null} diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 9161ac5..3780e2b 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -2,6 +2,7 @@ import React from "react"; import { useParams } from "react-router-dom"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; +import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker"; import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel"; import { getInvitationReferenceLabel } from "../components/client/invitationReference"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; @@ -130,10 +131,26 @@ export const buildDeliveryConfirmationPayload = ({ slot, invitation, searchDate, -}) => ({ - deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined, - deliveryTime: slot?.time || invitation?.deliveryTime || undefined, -}); + deliveryType = "delivery", + pickupDate, + pickupTimeSlot, +}) => { + if (deliveryType === "pickup") { + return { + deliveryType: "pickup", + pickupDate: pickupDate || slot?.date || undefined, + pickupTimeSlot: pickupTimeSlot || slot?.time || undefined, + deliveryDate: pickupDate || slot?.date || searchDate || invitation?.deliveryDate || undefined, + deliveryTime: pickupTimeSlot || slot?.time || undefined, + }; + } + + return { + deliveryType: "delivery", + deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined, + deliveryTime: slot?.time || invitation?.deliveryTime || undefined, + }; +}; export const buildSelectedSlotFromInvitation = (invitation, slots = []) => { if (!invitation?.deliveryDate) { @@ -163,6 +180,9 @@ export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) = : "По этому заказу согласование доставки завершено или передано логисту."; }; +const TAB_DELIVERY = "delivery"; +const TAB_PICKUP = "pickup"; + export const ClientDeliveryPage = () => { const { token } = useParams(); const [invitation, setInvitation] = React.useState(null); @@ -172,6 +192,7 @@ export const ClientDeliveryPage = () => { const [selectedSlotId, setSelectedSlotId] = React.useState(null); const [selectedSlot, setSelectedSlot] = React.useState(null); const [choiceSaved, setChoiceSaved] = React.useState(false); + const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY); const referenceDate = React.useMemo(() => new Date(), [token]); React.useEffect(() => { @@ -195,6 +216,10 @@ export const ClientDeliveryPage = () => { const loadedInvitation = await fetchDeliveryInvitation(token); if (!cancelled) { setInvitation(loadedInvitation); + // If invitation already has deliveryType=pickup, pre-select pickup tab + if (loadedInvitation?.deliveryType === "pickup") { + setActiveTab(TAB_PICKUP); + } } } catch (fetchError) { if (!cancelled) { @@ -248,6 +273,11 @@ export const ClientDeliveryPage = () => { token, deliveryTime: effectiveSelectedSlot.time, deliveryDate: effectiveSelectedSlot.date, + deliveryType: activeTab, + ...(activeTab === TAB_PICKUP ? { + pickupDate: effectiveSelectedSlot.date, + pickupTimeSlot: effectiveSelectedSlot.time, + } : {}), }); const loadedInvitation = await fetchDeliveryInvitation(token); setInvitation(loadedInvitation); @@ -323,22 +353,78 @@ export const ClientDeliveryPage = () => { {isChoiceSaved && savedChoiceLabel ? (

Ваш выбор

-

Сохранено: {savedChoiceLabel}

+

+ {invitation?.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}: {savedChoiceLabel} +

{getInvitationReferenceLabel(invitation)}

- Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор. + Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.

) : null} - {isActiveState && !isChoiceSaved && slots.length ? ( - + {isActiveState && !isChoiceSaved ? ( + <> + {/* Tab switcher */} +
+ + +
+ + {activeTab === TAB_DELIVERY && slots.length ? ( + + ) : null} + + {activeTab === TAB_PICKUP ? ( + + ) : null} + + {activeTab === TAB_DELIVERY && !slots.length ? ( + +

Нет доступных слотов для выбора доставки.

+
+ ) : null} + ) : null} {isActiveState && !isChoiceSaved ? ( @@ -346,6 +432,7 @@ export const ClientDeliveryPage = () => { invitation={invitation} selectedSlot={effectiveSelectedSlot} onConfirmChoice={handleSaveChoice} + deliveryType={activeTab} /> ) : !isActiveState && !isChoiceSaved ? ( @@ -369,4 +456,4 @@ export const ClientDeliveryPage = () => {
); -}; +}; \ No newline at end of file diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 6cd0df8..21d39ac 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { OrdersTable } from "../components/orders/OrdersTable"; @@ -34,7 +34,8 @@ const ROLE_SECTION = { }; export const DashboardPage = () => { - const { user, signOut } = useAuth(); + const { user, signOut, isSessionLoading } = useAuth(); + const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const userRole = user?.role; @@ -117,8 +118,19 @@ export const DashboardPage = () => { const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0]; const isGuideOpen = false; +const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"]; + + // Wait for session restore before deciding redirect + if (isSessionLoading) { + return null; + } + if (!user) { - return ; + return ; + } + + if (!ALLOWED_DASHBOARD_ROLES.includes(userRole)) { + return ; } const renderActiveSection = () => { diff --git a/src/pages/ForbiddenPage.jsx b/src/pages/ForbiddenPage.jsx new file mode 100644 index 0000000..f7da853 --- /dev/null +++ b/src/pages/ForbiddenPage.jsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { Button } from "../components/UI/Button"; +import { Panel } from "../components/UI/Panel"; + +export const ForbiddenPage = () => { + return ( +
+ +

Доступ ограничен

+

+ У вас нет прав для просмотра этой страницы. Обратитесь к администратору или войдите с другой учётной записью. +

+
+ + + + + + +
+
+
+ ); +}; diff --git a/src/pages/GroupDetailPage.jsx b/src/pages/GroupDetailPage.jsx index 079f483..4beea6f 100644 --- a/src/pages/GroupDetailPage.jsx +++ b/src/pages/GroupDetailPage.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { useNavigate, useParams, useLocation } from "react-router-dom"; +import { Navigate, useNavigate, useParams, useLocation } from "react-router-dom"; import { OrderDetailPanel } from "../components/orders/OrderDetailPanel"; import { Button } from "../components/UI/Button"; import { Panel } from "../components/UI/Panel"; @@ -7,13 +7,16 @@ import { useAuth } from "../context/AuthContext"; import { fetchDrivers } from "../services/supabase/userRepository"; import { useOrderGroups } from "../hooks/useOrderGroups"; +const ALLOWED_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"]; + export const GroupDetailPage = () => { const { groupId } = useParams(); const navigate = useNavigate(); const location = useLocation(); - const { user } = useAuth(); + const { user, isSessionLoading } = useAuth(); const userRole = user?.role; + // ALL hooks must be called before any early return (Rules of Hooks) const { allOrderGroups, selectedOrderGroupId, @@ -45,11 +48,7 @@ export const GroupDetailPage = () => { return () => { cancelled = true; }; }, []); - const order = allOrderGroups.find((g) => g.id === groupId) || - allOrderGroups.find((g) => g.id === selectedOrderGroupId) || - null; - - // Preserve the tab the user came from when going back + // ALL hooks must be called before any early return (Rules of Hooks) const handleGoBack = React.useCallback(() => { if (window.history.length > 1) { navigate(-1); @@ -58,6 +57,25 @@ export const GroupDetailPage = () => { } }, [navigate]); + // Wait for session restore before deciding redirect + if (isSessionLoading) { + return null; + } + + // Auth guard: redirect to login if not authenticated + if (!user) { + return ; + } + + // Role guard: only allowed roles can access group details + if (!ALLOWED_ROLES.includes(userRole)) { + return ; + } + + const order = allOrderGroups.find((g) => g.id === groupId) || + allOrderGroups.find((g) => g.id === selectedOrderGroupId) || + null; + return (
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 1ba3035..3d6e80c 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useSearchParams } from "react-router-dom"; import { ROLE_LABELS } from "../constants/roles"; import { useAuth } from "../context/AuthContext"; import { demoUsers } from "../data/mockAppData"; @@ -14,6 +14,9 @@ export const LoginPage = () => { const [otp, setOtp] = React.useState(""); const [error, setError] = React.useState(""); + const [searchParams] = useSearchParams(); + const redirectUrl = searchParams.get("redirect") || "/dashboard"; + const displayError = error || authError; const handleRequestOtp = async () => { @@ -60,7 +63,7 @@ export const LoginPage = () => { }; if (user) { - return ; + return ; } return ( diff --git a/src/router.jsx b/src/router.jsx index 26c86b7..2a84da7 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/DashboardPage"; import { GroupDetailPage } from "./pages/GroupDetailPage"; import { LoginPage } from "./pages/LoginPage"; import { NotFoundPage } from "./pages/NotFoundPage"; +import { ForbiddenPage } from "./pages/ForbiddenPage"; export const router = createBrowserRouter([ { @@ -24,6 +25,10 @@ export const router = createBrowserRouter([ path: "delivery/:token", element: , }, + { + path: "forbidden", + element: , + }, { path: "dashboard", element: , diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index d8077c7..31a4eb3 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -223,11 +223,13 @@ export const fetchDeliveryInvitation = async (token) => { } }; -export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) => { +export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, deliveryType, pickupDate, pickupTimeSlot }) => { if (isLocalClientInvitationToken(token)) { const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token); const invitation = cacheInvitation({ ...baseInvitation, + deliveryType: deliveryType || "delivery", + ...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}), deliveryDate, deliveryTime, state: "confirmed", @@ -242,6 +244,9 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime p_token: token, p_delivery_date: deliveryDate, p_delivery_time: deliveryTime, + p_delivery_type: deliveryType || "delivery", + p_pickup_date: pickupDate || null, + p_pickup_time_slot: pickupTimeSlot || null, }); }; diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index e9eaac9..b24c798 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -12,6 +12,7 @@ export const DELIVERY_GROUP_STATUS_LABELS = { delivered: "Доставлено", problem: "Проблема", paid_storage: "Платное хранение", + pickup: "Самовывоз", cancelled: "Отменено", }; diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 85ed179..c699cb2 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -61,6 +61,26 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone); const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date); const orderNumbers = toStringArray(row.order_numbers); + + // Extract ALL bill numbers from source_orders (1C sends full orderList in every source_order) + const allBillNumbers = (() => { + const srcOrders = row.source_orders; + if (!Array.isArray(srcOrders) || !srcOrders.length) return orderNumbers; + const seen = new Set(); + const result = []; + const normalizeNom = (nom) => String(nom || '').replace(/\\\\/g, '\\').trim(); + for (const src of srcOrders) { + if (src && Array.isArray(src.orderList)) { + for (const ol of src.orderList) { + if (ol && ol.nom) { + const n = normalizeNom(ol.nom); + if (n && !seen.has(n)) { seen.add(n); result.push(n); } + } + } + } + } + return result.length > 0 ? result : orderNumbers; + })(); const inferredOrderCount = orderNumbers.length; const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount); const readyCount = toNumber( @@ -140,6 +160,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { readyCount, notReadyCount, orderNumbers, + allBillNumbers, status: row.status || "draft", smsSentAt: row.sms_sent_at || null, firstSmsSentAt: row.first_sms_sent_at || null, @@ -168,13 +189,17 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { deliveryDate, deliveryTime, deliveryDateSource: row.delivery_date_source || null, + deliveryType: row.delivery_type || "delivery", + pickupDate: row.pickup_date || null, + pickupTimeSlot: row.pickup_time_slot || null, + driverShipmentData: row.driver_shipment_data || null, deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: rawDeliveryHalfDay, deliveryTime: rawDeliveryTime, deliveryWindow: row.delivery_window, sourceOrders: row.source_orders, }), - orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны", + orderNumberSummary: allBillNumbers.length ? allBillNumbers.join(", ") : "Номера не указаны", searchText: [ row.group_key, customerName, @@ -189,6 +214,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { deliveryStatus, getOrderGroupDeliveryStatusLabel(deliveryStatus), orderNumbers.join(" "), + allBillNumbers.join(" "), row.status, getOrderGroupStatusLabel(row.status), getOrderGroupDeliveryHalfDay({ @@ -207,19 +233,28 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { }; }; +const ORDER_GROUP_SELECT_FIELDS = `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, order_list, order_list_structured, 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, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot`; + export const updateOrderGroupDeliveryChoice = async ({ orderGroupId, deliveryDate, deliveryTime, + deliveryType, + pickupDate, + pickupTimeSlot, }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); + const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed"; const updateResult = await client .from("order_groups") .update({ - delivery_status: "agreed", + delivery_status: effectiveDeliveryStatus, delivery_date: deliveryDate, delivery_time: deliveryTime, + delivery_type: deliveryType || "delivery", + pickup_date: deliveryType === "pickup" ? pickupDate : null, + pickup_time_slot: deliveryType === "pickup" ? pickupTimeSlot : null, delivery_date_source: "manual", notification_status: "confirmed", updated_at: new Date().toISOString(), @@ -232,7 +267,20 @@ export const updateOrderGroupDeliveryChoice = async ({ 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, order_list, order_list_structured, 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, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") + .select(ORDER_GROUP_SELECT_FIELDS) + .eq("id", orderGroupId) + .single(); + + if (error) { + throw error; + } + + await logAction({ orderGroupId, action: "date_assigned", newValue: (deliveryType === "pickup" ? "pickup: " : "manual: ") + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual", delivery_type: deliveryType, pickup_date: pickupDate, pickup_time_slot: pickupTimeSlot } }).catch(() => {}); + + return mapOrderGroupRowToDeliveryGroup(data); + }, "Ошибка сохранения согласования доставки"); +}; + .eq("id", orderGroupId) .single(); @@ -386,7 +434,7 @@ export const fetchOrderGroups = async () => { const client = requireSupabase(); 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, order_list, order_list_structured, 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, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") + .select(ORDER_GROUP_SELECT_FIELDS) .order("updated_at", { ascending: false }); if (error) { @@ -408,4 +456,3 @@ export const fetchOrderGroups = async () => { return group; }).filter(Boolean); }, "Ошибка загрузки групп доставки"); -}; \ No newline at end of file diff --git a/src/supabaseClient.jsx b/src/supabaseClient.jsx index 7271e57..05f1200 100644 --- a/src/supabaseClient.jsx +++ b/src/supabaseClient.jsx @@ -6,31 +6,31 @@ export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey); /** - * Secure session storage for Supabase auth tokens. + * Secure storage for Supabase auth tokens. + * + * Uses localStorage so the session is available across tabs (critical for + * direct links like /dashboard/group/:id opening in a new tab). * * Security properties: - * - Uses sessionStorage (dies on tab close, not shared across tabs) - * - Tokens are obfuscated with a per-session random key before storage - * - No plaintext tokens in sessionStorage — reduces impact of XSS + * - Tokens are obfuscated with a per-browser random key stored in localStorage + * - No plaintext tokens in localStorage — reduces impact of XSS * - Auto-clears on detection of tampered/missing data + * - Session survives tab close (unlike sessionStorage) — required for cross-tab * * This is NOT as secure as httpOnly cookies (which require server-side SSR), - * but provides significantly better protection than plaintext localStorage: - * - Tokens don't persist across browser restarts - * - Tokens aren't shared across tabs (reduces cross-tab attacks) - * - Obfuscation adds friction for casual XSS token theft + * but is the standard approach for SPA auth with Supabase. */ const STORAGE_KEY = "supersam-auth"; const KEY_KEY = "supersam-ak"; function _getKey() { - let key = sessionStorage.getItem(KEY_KEY); + let key = localStorage.getItem(KEY_KEY); if (!key) { key = crypto.getRandomValues(new Uint8Array(32)).reduce( (s, b) => s + b.toString(16).padStart(2, "0"), "" ); - sessionStorage.setItem(KEY_KEY, key); + localStorage.setItem(KEY_KEY, key); } return key; } @@ -60,15 +60,15 @@ async function _deobfuscate(obfuscated) { return new TextDecoder().decode(result); } catch { // Tampered data — clear everything - sessionStorage.removeItem(STORAGE_KEY); - sessionStorage.removeItem(KEY_KEY); + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(KEY_KEY); return ""; } } const secureStorage = { getItem: async (key) => { - const raw = sessionStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const data = JSON.parse(raw); @@ -76,34 +76,34 @@ const secureStorage = { if (typeof value !== "string") return null; return await _deobfuscate(value); } catch { - sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); return null; } }, setItem: async (key, value) => { let data; try { - const raw = sessionStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(STORAGE_KEY); data = raw ? JSON.parse(raw) : {}; } catch { data = {}; } data[key] = await _obfuscate(value); - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }, removeItem: async (key) => { - const raw = sessionStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const data = JSON.parse(raw); delete data[key]; if (Object.keys(data).length === 0) { - sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); } else { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } } catch { - sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); } }, }; diff --git a/supabase/functions/_shared/chatbot.ts b/supabase/functions/_shared/chatbot.ts index ba41bf3..12afe3c 100644 --- a/supabase/functions/_shared/chatbot.ts +++ b/supabase/functions/_shared/chatbot.ts @@ -1,4 +1,4 @@ -import { createClient } from "@supabase/supabase-js"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8"; import { getOrderUpdateForInboundAction } from "./workflow.ts"; export type ProviderName = "telegram" | "vk" | "messenger_max"; diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index 70e7ade..5d7450e 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -120,15 +120,25 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { }; export const buildDefaultDatedAvailableSlots = (now = new Date()) => { - const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10); + const CRIMEA_TZ = "Europe/Simferopol"; + + const formatCrimeaDate = (date: Date) => { + return new Intl.DateTimeFormat("en-CA", { + timeZone: CRIMEA_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); + }; + const addDays = (date: Date, days: number) => { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; }; - const firstDay = formatIsoDate(addDays(now, 1)); - const secondDay = formatIsoDate(addDays(now, 2)); + const firstDay = formatCrimeaDate(addDays(now, 1)); + const secondDay = formatCrimeaDate(addDays(now, 2)); return [ `${firstDay}, Первая половина дня`, diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts index 1b13906..12682e1 100644 --- a/supabase/functions/_shared/security.ts +++ b/supabase/functions/_shared/security.ts @@ -1,399 +1,172 @@ -type CorsMode = "public" | "integration" | "webhook"; +import { createClient } from 'npm:@supabase/supabase-js@2'; -type JsonBodyOptions = { - maxBytes: number; - errorMessage?: string; -}; +const ALLOWED_ORIGINS = [ + 'https://supa.supersamsev.ru', + 'https://dost.supersamsev.ru', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:3000', + 'https://supasevdev.mkn8n.ru', +]; -type RateLimitOptions = { +export function createServiceClient() { + const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''; + return createClient(supabaseUrl, serviceRoleKey); +} + +export function getClientIp(request: Request): string { + const xff = request.headers.get('x-forwarded-for'); + if (xff) return xff.split(',')[0].trim(); + return request.headers.get('x-real-ip') || 'unknown'; +} + +export function getCorsHeaders(request: Request, _access: 'public' | 'private') { + const origin = request.headers.get('origin') || ''; + if (!origin) { + return { + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0], + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', + 'Access-Control-Max-Age': '86400', + }; + } + const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o)); + if (!allowed) return null; + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', + 'Access-Control-Max-Age': '86400', + }; +} + +export function preflightResponse(request: Request, access: 'public' | 'private') { + const corsHeaders = getCorsHeaders(request, access); + if (!corsHeaders) { + return new Response('Origin not allowed', { status: 403 }); + } + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (corsHeaders) Object.assign(headers, corsHeaders); + return new Response(JSON.stringify(body), { status, headers }); +} + +export async function hashText(text: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +interface JsonBodyResult { + body: T; +} + +export async function readJsonBody(request: Request, options?: { maxBytes?: number }): Promise> { + const maxBytes = options?.maxBytes ?? 1024 * 1024; + const reader = request.body?.getReader(); + if (!reader) throw new Error('No body'); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.length; + if (totalBytes > maxBytes) { + reader.cancel(); + throw Object.assign(new Error('Request body too large'), { status: 413 }); + } + chunks.push(value); + } + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + const text = new TextDecoder().decode(combined); + const body = JSON.parse(text) as T; + return { body }; +} + +interface RateLimitOptions { scope: string; key: string; maxCount: number; windowSeconds: number; - blockSeconds?: number; -}; + blockSeconds: number; +} -type RateLimitResult = { - allowed: boolean; - currentCount: number; - limitCount: number; - blockedUntil: string | null; - windowStart: string; -}; - -type IntegrationAuthOptions = { - rawBody: string; - secretEnvNames?: string[]; - tokenEnvNames?: string[]; - signatureHeader?: string; - timestampHeader?: string; - requestIdHeader?: string; - allowedClockSkewSeconds?: number; -}; - -const DEFAULT_LOCAL_ORIGINS = [ - "http://localhost:5173", - "http://localhost:4173", - "http://127.0.0.1:5173", - "http://127.0.0.1:4173", -]; - -const normalizeOrigin = (value: string) => value.replace(/\/$/, ""); - -const splitList = (value: string | null | undefined) => - (value || "") - .split(",") - .map((item) => normalizeOrigin(item.trim())) - .filter(Boolean); - -const getRequestOrigin = (request: Request) => { - const origin = request.headers.get("origin"); - if (origin) { - return normalizeOrigin(origin); - } - - const referer = request.headers.get("referer"); - if (!referer) { - return ""; - } - - try { - return normalizeOrigin(new URL(referer).origin); - } catch { - return ""; - } -}; - -const readEnv = (name: string) => { - try { - if (typeof Deno === "undefined") { - return ""; - } - return Deno.env.get(name) || ""; - } catch { - return ""; - } -}; - -const isLocalhostOrigin = (origin: string) => - /:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); - -const resolveAllowedOrigins = (mode: CorsMode) => { - const publicOrigins = [ - ...splitList(readEnv("APP_ALLOWED_ORIGINS")), - ...splitList(readEnv("PUBLIC_APP_URL")), - ...splitList(readEnv("APP_PUBLIC_URL")), - ]; - const integrationOrigins = [ - ...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")), - ...splitList(readEnv("PUBLIC_APP_URL")), - ]; - const webhookOrigins = [ - ...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")), - ...splitList(readEnv("PUBLIC_APP_URL")), - ]; - - const configured = - mode === "public" - ? publicOrigins - : mode === "integration" - ? integrationOrigins - : webhookOrigins; - - if (configured.length > 0) { - return Array.from(new Set(configured)); - } - - return []; -}; - -export class HttpError extends Error { +class RateLimitError extends Error { status: number; - - constructor(status: number, message: string) { + constructor(message: string, status: number) { super(message); this.status = status; - this.name = "HttpError"; } } -export const jsonResponse = ( - body: unknown, - status = 200, - headers: HeadersInit = {}, -) => - new Response(JSON.stringify(body), { - status, - headers: { - "Content-Type": "application/json", - ...headers, - }, - }); +export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { + const { scope, key, maxCount, windowSeconds, blockSeconds } = options; + const tableName = 'rate_limits'; + const now = new Date(); -export const getCorsHeaders = (request: Request, mode: CorsMode) => { - const origin = getRequestOrigin(request); - const allowedOrigins = resolveAllowedOrigins(mode); + const { data: blocked } = await supabase + .from(tableName) + .select('blocked_until') + .eq('scope', scope) + .eq('rate_key', key) + .gt('blocked_until', now.toISOString()) + .limit(1); - if (!origin) { - if (allowedOrigins.length === 0) { - return null; - } - - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Max-Age": "86400", - Vary: "Origin", - } satisfies Record; + if (blocked && blocked.length > 0) { + throw new RateLimitError('Too many requests. Please try again later.', 429); } - const isAllowed = - allowedOrigins.length === 0 - ? false - : allowedOrigins.some((allowedOrigin) => { - if (allowedOrigin === "*") { - return true; - } - - return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`); - }) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin)); - - if (!isAllowed) { - return null; - } - - return { - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Max-Age": "86400", - Vary: "Origin", - } satisfies Record; -}; - -export const preflightResponse = (request: Request, mode: CorsMode) => { - const corsHeaders = getCorsHeaders(request, mode); - if (!corsHeaders) { - return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); - } - - return new Response("ok", { - status: 204, - headers: corsHeaders, - }); -}; - -export const assertAllowedOrigin = (request: Request, mode: CorsMode) => { - const corsHeaders = getCorsHeaders(request, mode); - if (!corsHeaders) { - throw new HttpError(403, "Origin not allowed"); - } - - return corsHeaders; -}; - -export const readJsonBody = async >( - request: Request, - options: JsonBodyOptions, -): Promise<{ body: T; rawBody: string }> => { - const rawBody = await request.clone().text(); - const byteLength = new TextEncoder().encode(rawBody).length; - - if (byteLength > options.maxBytes) { - throw new HttpError(413, options.errorMessage || "Payload too large"); - } - - if (!rawBody.trim()) { - throw new HttpError(400, "Request body is required"); - } - - try { - return { - body: JSON.parse(rawBody) as T, - rawBody, - }; - } catch { - throw new HttpError(400, "Invalid JSON payload"); - } -}; - -export const getClientIp = (request: Request) => { - const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || ""; - return forwardedFor.split(",")[0]?.trim() || "unknown"; -}; - -export const sha256Hex = async (value: string) => { - const bytes = new TextEncoder().encode(value); - const digest = await crypto.subtle.digest("SHA-256", bytes); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -}; - -export const hashText = sha256Hex; - -const hmacHex = async (secret: string, value: string) => { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); - return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -}; - -export const verifyInternalRequest = async ( - request: Request, - rawBody: string, - options: IntegrationAuthOptions = { rawBody }, -) => { - const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"]; - const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"]; - const bearerToken = request.headers.get("authorization") || ""; - const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : ""; - const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || ""; - const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || ""; - const signature = request.headers.get(options.signatureHeader || "x-signature") || ""; - const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean); - const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean); - - if (token && sharedTokens.some((candidate) => candidate === token)) { - return { requestId, authenticatedBy: "bearer" as const }; - } - - if (sharedSecrets.length === 0) { - throw new HttpError(401, "Integration auth is not configured"); - } - - if (!timestamp || !signature) { - throw new HttpError(401, "Missing integration signature"); - } - - const timestampNumber = Number(timestamp); - if (!Number.isFinite(timestampNumber)) { - throw new HttpError(401, "Invalid integration timestamp"); - } - - const now = Date.now(); - const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000; - if (Math.abs(now - timestampNumber) > allowedSkew) { - throw new HttpError(401, "Stale integration request"); - } - - const payload = `${timestamp}.${rawBody}`; - const expectedSignatures = await Promise.all( - sharedSecrets.map(async (secret) => hmacHex(secret, payload)), - ); - - if (!expectedSignatures.some((candidate) => candidate === signature)) { - throw new HttpError(401, "Invalid integration signature"); - } - - return { requestId, authenticatedBy: "hmac" as const }; -}; - -export const maskPhoneNumber = (phone: string | null | undefined) => { - const value = String(phone || "").trim(); - if (!value) { - return null; - } - - const digits = value.replace(/\D/g, ""); - if (digits.length < 4) { - return value; - } - - const tail = digits.slice(-4); - const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+"; - return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`; -}; - -export const maskCustomerName = (name: string | null | undefined) => { - const value = String(name || "").trim(); - if (!value) { - return null; - } - - const parts = value.split(/\s+/).filter(Boolean); - if (parts.length === 1) { - return `${parts[0].slice(0, 1)}.`; - } - - return `${parts[0]} ${parts[1].slice(0, 1)}.`; -}; - -export const maskOrderNumber = (orderNumber: string | null | undefined) => { - const value = String(orderNumber || "").trim(); - if (!value) { - return null; - } - - if (value.length <= 4) { - return value; - } - - 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: ( - name: string, - params: Record, - ) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>; - }, - options: RateLimitOptions, -) => { - const { data, error } = await supabase.rpc("check_rate_limit", { - p_scope: options.scope, - p_key: options.key, - p_max_count: options.maxCount, - p_window_seconds: options.windowSeconds, - p_block_seconds: options.blockSeconds || 0, - }); + const windowStart = new Date(now.getTime() - windowSeconds * 1000); + const { data: recent, error } = await supabase + .from(tableName) + .select('id, count') + .eq('scope', scope) + .eq('rate_key', key) + .gte('window_start', windowStart.toISOString()); if (error) { - throw error; + console.error('Rate limit check error:', error); + return; } - if (!data?.allowed) { - throw new HttpError(429, "Too many requests"); + const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0; + + if (totalCount >= maxCount) { + const blockedUntil = new Date(now.getTime() + blockSeconds * 1000); + await supabase + .from(tableName) + .update({ blocked_until: blockedUntil.toISOString() }) + .eq('scope', scope) + .eq('rate_key', key) + .gte('window_start', windowStart.toISOString()); + throw new RateLimitError('Too many requests. Please try again later.', 429); } - return data; -}; + const existingRow = recent?.[0]; + if (existingRow) { + await supabase + .from(tableName) + .update({ count: (existingRow as { count: number }).count + 1 }) + .eq('id', (existingRow as { id: string }).id); + } else { + await supabase.from(tableName).insert({ + scope, + rate_key: key, + window_start: now.toISOString(), + count: 1, + blocked_until: null, + }); + } +} \ No newline at end of file diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index f105c99..982cd83 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -24,6 +24,9 @@ type ConfirmBody = { token?: string; deliveryDate?: string; deliveryTime?: string; + deliveryType?: string; + pickupDate?: string; + pickupTimeSlot?: string; }; const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); @@ -36,6 +39,7 @@ const resolveRequestedSlot = ( }, body: ConfirmBody, ) => { + const deliveryType = body.deliveryType || "delivery"; const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); @@ -43,6 +47,11 @@ const resolveRequestedSlot = ( return null; } + // For pickup, we allow slots outside the invitation's available_slots + if (deliveryType === "pickup") { + return { deliveryDate, deliveryTime, deliveryType }; + } + const slotLabel = `${deliveryDate}, ${deliveryTime}`; const availableSlots = invitation.available_slots || []; @@ -50,7 +59,7 @@ const resolveRequestedSlot = ( return null; } - return { deliveryDate, deliveryTime }; + return { deliveryDate, deliveryTime, deliveryType }; }; Deno.serve(async (request) => { @@ -127,6 +136,9 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); } + const deliveryType = body.deliveryType || "delivery"; + const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed"; + if (invitation.order_group_id) { const { data: currentGroup, error: groupError } = await supabase .from("order_groups") @@ -177,15 +189,23 @@ Deno.serve(async (request) => { throw invitationUpdateError; } + const groupUpdateData: Record = { + delivery_status: effectiveDeliveryStatus, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }; + + if (deliveryType === "pickup") { + groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null; + groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null; + } + const { error: groupUpdateError } = await supabase .from("order_groups") - .update({ - delivery_status: "agreed", - delivery_date: requestedSlot.deliveryDate, - delivery_time: requestedSlot.deliveryTime, - notification_status: "confirmed", - updated_at: new Date().toISOString(), - }) + .update(groupUpdateData) .eq("id", invitation.order_group_id); if (groupUpdateError) { @@ -197,10 +217,13 @@ Deno.serve(async (request) => { order_group_id: invitation.order_group_id, action: "client_confirmed", old_value: currentGroup.delivery_status, - new_value: "agreed", + new_value: effectiveDeliveryStatus, details: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, source: "auto", }, }); @@ -215,6 +238,9 @@ Deno.serve(async (request) => { delivery_invitation_id: invitation.id, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, }); @@ -222,7 +248,7 @@ Deno.serve(async (request) => { { ok: true, orderGroupId: invitation.order_group_id, - deliveryStatus: "agreed", + deliveryStatus: effectiveDeliveryStatus, }, 200, corsHeaders, @@ -314,6 +340,9 @@ Deno.serve(async (request) => { new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, }); @@ -329,6 +358,9 @@ Deno.serve(async (request) => { payload: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, }); diff --git a/supabase/functions/main/index.ts b/supabase/functions/main/index.ts new file mode 100644 index 0000000..cf93be4 --- /dev/null +++ b/supabase/functions/main/index.ts @@ -0,0 +1,168 @@ +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// Create JWKS for ES256/RS256 tokens (newer tokens) +let SUPABASE_JWT_KEYS: ReturnType | null = null +if (SUPABASE_URL) { + try { + SUPABASE_JWT_KEYS = jose.createRemoteJWKSet( + new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL) + ) + } catch (e) { + console.error('Failed to fetch JWKS from SUPABASE_URL:', e) + } +} + +/** + * Extract JWT token from Authorization header + * + * Parses the Authorization header to extract the Bearer token. + * Expects format: "Bearer " + * + * @param req - The HTTP request object + * @returns The JWT token string + * @throws Error if Authorization header is missing or malformed + */ +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function isValidLegacyJWT(jwt: string): Promise { + if (!JWT_SECRET) { + console.error('JWT_SECRET not available for HS256 token verification') + return false + } + + const encoder = new TextEncoder(); + const secretKey = encoder.encode(JWT_SECRET) + + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error('Symmetric Legacy JWT verification error', e); + return false; + } + return true; +} + +async function isValidJWT(jwt: string): Promise { + if (!SUPABASE_JWT_KEYS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + } catch (e) { + console.error('Asymmetric JWT verification error', e); + return false + } + + return true; +} + +/** + * Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms + * + * This function automatically detects the algorithm used in the token and applies + * the appropriate verification method: + * - HS256: Uses JWT_SECRET (symmetric key) + * - ES256/RS256: Uses JWKS endpoint (asymmetric public keys) + * + * This fix ensures compatibility with both legacy tokens and newer asymmetric tokens, + * resolving the "Key for the ES256 algorithm must be of type CryptoKey" error. + * + * @param jwt - The JWT token string to verify + * @returns Promise resolving to true if verification succeeds, false otherwise + */ +async function isValidHybridJWT(jwt: string): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) + + if (jwtAlgorithm === 'HS256') { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) + + return await isValidLegacyJWT(jwt) + } + + if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { + return await isValidJWT(jwt) + } + + return false; +} + +Deno.serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await isValidHybridJWT(token); + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = "/home/deno/functions/import_map.json" + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/supabase/functions/request-otp/index.ts b/supabase/functions/request-otp/index.ts index 5f8a4b4..f28569f 100644 --- a/supabase/functions/request-otp/index.ts +++ b/supabase/functions/request-otp/index.ts @@ -1,4 +1,4 @@ -import { createAnonClient } from "../_shared/chatbot.ts"; +import { createServiceClient } from "../_shared/security.ts"; import { getClientIp, getCorsHeaders, @@ -14,6 +14,17 @@ const MAX_BODY_BYTES = 8 * 1024; const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +function generateOtp(): string { + const digits = "0123456789"; + let otp = ""; + const arr = new Uint8Array(6); + crypto.getRandomValues(arr); + for (let i = 0; i < 6; i++) { + otp += digits[arr[i] % digits.length]; + } + return otp; +} + Deno.serve(async (request) => { if (request.method === "OPTIONS") { return preflightResponse(request, "public"); @@ -38,7 +49,7 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); } - const supabase = createAnonClient(); + const supabase = createServiceClient(); const emailHash = await hashText(email); const ipHash = await hashText(getClientIp(request)); @@ -50,15 +61,50 @@ Deno.serve(async (request) => { blockSeconds: 1800, }); - const { error } = await supabase.auth.signInWithOtp({ + // Check if user exists in our users table + const { data: users, error: userError } = await supabase + .from("users") + .select("id, name, roles(name)") + .eq("email", email) + .limit(1); + + if (userError || !users || users.length === 0) { + return jsonResponse({ ok: false, error: "Email не найден в системе. Обратитесь к администратору." }, 400, corsHeaders); + } + + const user = users[0]; + const userName = user.name || null; + const userRole = user.roles?.name || null; + + // Invalidate previous unverified OTPs for this email + await supabase + .from("login_otps") + .delete() + .eq("email", email) + .eq("verified", false); + + // Generate OTP + const otp = generateOtp(); + const otpCodeHash = await hashText(otp); + const clientIp = getClientIp(request); + const userAgent = request.headers.get("user-agent") || null; + + // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n + // n8n will clear otp_code after sending SMS + const { error: insertError } = await supabase.from("login_otps").insert({ email, - options: { - shouldCreateUser: false, - }, + name: userName, + role: userRole, + otp_code: otp, + otp_code_hash: otpCodeHash, + ip_address: clientIp, + user_agent: userAgent, + verified: false, }); - if (error) { - return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); + if (insertError) { + console.error("Failed to insert OTP:", insertError); + return jsonResponse({ ok: false, error: "Failed to generate OTP" }, 500, corsHeaders); } return jsonResponse({ ok: true }, 200, corsHeaders); diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts index 9d294c2..1fed0f0 100644 --- a/supabase/functions/verify-otp/index.ts +++ b/supabase/functions/verify-otp/index.ts @@ -1,4 +1,4 @@ -import { createAnonClient } from "../_shared/chatbot.ts"; +import { createServiceClient } from "../_shared/security.ts"; import { getClientIp, getCorsHeaders, @@ -7,10 +7,10 @@ import { preflightResponse, readJsonBody, requireRateLimit, - requireSameOrigin, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 8 * 1024; +const OTP_EXPIRY_SECONDS = 600; // 10 minutes const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); @@ -29,19 +29,6 @@ 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, @@ -57,7 +44,7 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); } - const supabase = createAnonClient(); + const supabase = createServiceClient(); const emailHash = await hashText(email); const ipHash = await hashText(getClientIp(request)); @@ -69,21 +56,118 @@ Deno.serve(async (request) => { blockSeconds: 1800, }); - const { data, error } = await supabase.auth.verifyOtp({ + // 1. Find the most recent unverified OTP for this email + const { data: otpRecords, error: fetchError } = await supabase + .from("login_otps") + .select("*") + .eq("email", email) + .eq("verified", false) + .order("created_at", { ascending: false }) + .limit(1); + + if (fetchError || !otpRecords || otpRecords.length === 0) { + return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders); + } + + const otpRecord = otpRecords[0]; + + // 2. Check expiry (10 minutes) + const createdAt = new Date(otpRecord.created_at); + const now = new Date(); + const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000; + + if (elapsedSeconds > OTP_EXPIRY_SECONDS) { + await supabase.from("login_otps").delete().eq("id", otpRecord.id); + return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders); + } + + // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records) + const submittedOtpHash = await hashText(otp); + let otpMatches = false; + + if (otpRecord.otp_code_hash) { + // New flow: compare SHA-256 hashes + otpMatches = otpRecord.otp_code_hash === submittedOtpHash; + } else if (otpRecord.otp_code) { + // Legacy fallback: plaintext comparison for old records + otpMatches = otpRecord.otp_code === otp; + } + + if (!otpMatches) { + return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders); + } + + // 4. Mark as verified and clear plaintext if present + await supabase + .from("login_otps") + .update({ verified: true, otp_code: "" }) + .eq("id", otpRecord.id); + + // Delete all other unverified OTPs for this email + await supabase + .from("login_otps") + .delete() + .eq("email", email) + .eq("verified", false); + + // 5. Find user by email to get user_id + const { data: users } = await supabase + .from("users") + .select("id, name, roles(name)") + .eq("email", email) + .limit(1); + + if (!users || users.length === 0) { + return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders); + } + + const userId = users[0].id; + const userName = users[0].name || null; + const userRole = users[0].roles?.name || null; + + // Update the login_otps record with user info + await supabase + .from("login_otps") + .update({ name: userName, role: userRole }) + .eq("id", otpRecord.id); + + // 6. Create session using Supabase admin API + const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", email, - token: otp, - type: "email", }); - if (error) { - return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); + if (linkError || !linkData) { + console.error("generateLink error:", linkError); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); } + const generatedLink = linkData as any; + const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash; + + if (!tokenHash) { + console.error("No token in generateLink response"); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); + } + + const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({ + type: "magiclink", + token_hash: tokenHash, + }); + + if (verifyError) { + console.error("verifyOtp error:", verifyError); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); + } + + const session = verifyData.session; + const user = verifyData.user; + return jsonResponse( { ok: true, - session: data.session || null, - user: data.session?.user || null, + session: session || null, + user: user || null, }, 200, corsHeaders, @@ -103,4 +187,4 @@ Deno.serve(async (request) => { corsHeaders, ); } -}); +}); \ No newline at end of file diff --git a/volumes/functions/README.md b/volumes/functions/README.md new file mode 100644 index 0000000..ee5ff9c --- /dev/null +++ b/volumes/functions/README.md @@ -0,0 +1,83 @@ +# Edge Functions + +## `chatbot-webhook` + +Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в +`chat_messages` и при необходимости обновляет статус заказа и `order_history`. + +Требует подпись `X-Signature` или `Authorization: Bearer `, а также +ограничивает частоту входящих событий. + +Пример вызова: + +```bash +curl -X POST \ + 'https://.supabase.co/functions/v1/chatbot-webhook?provider=telegram' \ + -H 'Content-Type: application/json' \ + -d '{ + "order_id": "uuid", + "text": "Подтверждаю", + "action": "confirm_delivery", + "external_message_id": "tg-42", + "payload": {"slot_id": "slot-1"} + }' +``` + +## `send-chatbot-message` + +Принимает исходящее сообщение, подготавливает dispatch в нужный канал и логирует отправку в +`chat_messages`. + +Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в +`Ожидает ответа клиента` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`. + +Ожидаемые переменные: + +- `SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` +- `INTEGRATION_API_KEY` +- `INTEGRATION_WEBHOOK_SECRET` +- `TELEGRAM_BOT_TOKEN` +- `VK_BOT_TOKEN` +- `MESSENGER_MAX_TOKEN` + +## `request-otp` + +Отправляет код входа по email после проверки лимитов по IP и адресу. Используется страницей +логина вместо прямого вызова `supabase.auth.signInWithOtp` из браузера. + +## `verify-otp` + +Проверяет код входа, тоже с rate limit, и возвращает session для установки в клиенте. + +## `create-delivery-invitation` + +Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет +`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL. + +Обязательная переменная окружения: + +- `PUBLIC_APP_URL` + +## `get-delivery-invitation` + +Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа +актуального статуса заказа. + +## `confirm-delivery-choice` + +Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает +историю события. + +## `update-order-group-delivery-choice` + +Фиксирует ручное согласование доставки по группе `order_groups`. +Используется менеджером или логистом, когда клиент согласовал дату и половину дня напрямую. + +## `transfer-to-logistics` + +Используется для ручной передачи заказа логисту или перевода в `Платное хранение`. + +## `report-delivery-result` + +Фиксирует итог доставки, включая успешную доставку и проблемные сценарии. diff --git a/volumes/functions/_shared/chatbot.ts b/volumes/functions/_shared/chatbot.ts new file mode 100644 index 0000000..12afe3c --- /dev/null +++ b/volumes/functions/_shared/chatbot.ts @@ -0,0 +1,72 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8"; +import { getOrderUpdateForInboundAction } from "./workflow.ts"; + +export type ProviderName = "telegram" | "vk" | "messenger_max"; + +export type NormalizedChatEvent = { + provider: ProviderName; + orderId: string; + externalMessageId: string | null; + senderType: "client" | "bot" | "system"; + text: string; + payload: Record; + action: "confirm_delivery" | "reschedule" | "cancel_delivery" | "unknown"; +}; + +export const createServiceClient = () => { + const supabaseUrl = Deno.env.get("SUPABASE_URL") || ""; + const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; + 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, + headers: { + "Content-Type": "application/json", + }, + }); + +export const normalizeIncomingEvent = ( + provider: ProviderName, + body: Record, +): NormalizedChatEvent => { + const payload = (body.payload as Record) || {}; + + return { + provider, + orderId: String(body.order_id || payload.order_id || ""), + externalMessageId: body.external_message_id ? String(body.external_message_id) : null, + senderType: "client", + text: String(body.text || payload.text || ""), + payload, + action: resolveAction(body.action || payload.action), + }; +}; + +export const resolveAction = (action: unknown): NormalizedChatEvent["action"] => { + switch (String(action || "").toLowerCase()) { + case "confirm": + case "confirm_delivery": + return "confirm_delivery"; + case "reschedule": + return "reschedule"; + case "cancel": + case "cancel_delivery": + return "cancel_delivery"; + default: + return "unknown"; + } +}; + +export const orderUpdateByAction = (action: NormalizedChatEvent["action"]) => + getOrderUpdateForInboundAction(action); + +export const channelFromProvider = (provider: ProviderName) => provider; diff --git a/volumes/functions/_shared/delivery-invitations.test.ts b/volumes/functions/_shared/delivery-invitations.test.ts new file mode 100644 index 0000000..1d5b5ea --- /dev/null +++ b/volumes/functions/_shared/delivery-invitations.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_AVAILABLE_SLOTS, + buildPublicInvitationView, + getClientInvitationStateFromOrderStatus, + getOrderUpdateForDeliveryInvitationAction, + isInvitationExpired, + normalizeAvailableSlots, +} from "./delivery-invitations"; + +describe("delivery invitation helpers", () => { + it("maps invitation creation to awaiting customer response", () => { + expect(getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation")).toEqual({ + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }); + }); + + it("maps manual logistics transfer to the logistics handoff status", () => { + expect(getOrderUpdateForDeliveryInvitationAction("transfer_to_logistics")).toEqual({ + status: "Передан логисту", + deliveryAgreementStatus: "Нет ответа", + }); + }); + + it("derives public client state from the current order status", () => { + expect(getClientInvitationStateFromOrderStatus("Ожидает ответа клиента")).toBe("awaiting_choice"); + expect(getClientInvitationStateFromOrderStatus("Передан логисту")).toBe("transferred_to_logistics"); + expect(getClientInvitationStateFromOrderStatus("Платное хранение")).toBe("paid_storage"); + expect(getClientInvitationStateFromOrderStatus("Доставлен")).toBe("delivered"); + }); + + it("normalizes delivery slots and falls back to the default list", () => { + expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]); + expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS); + }); + + it("marks expired and revoked invitations as inactive", () => { + expect( + isInvitationExpired({ + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + expires_at: "2026-04-01T00:00:00.000Z", + }, new Date("2026-04-02T00:00:00.000Z")), + ).toBe(true); + + expect( + isInvitationExpired({ + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + revoked_at: "2026-04-01T00:00:00.000Z", + }), + ).toBe(true); + }); + + it("masks customer contact details in the public invitation view", () => { + const invitation = buildPublicInvitationView( + { + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + customer_name: "Мария Волкова", + customer_phone: "+7 978 123-45-67", + order_number: "CD-240031", + available_slots: ["2026-04-15, До обеда"], + }, + { + order_number: "CD-240031", + customer: { + name: "Мария Волкова", + phone: "+7 978 123-45-67", + items: [{ name: "Кухонный гарнитур", quantity: "1 комплект" }], + }, + }, + ); + + expect(invitation.customerName).toBe("Мария В."); + expect(invitation.customerPhone).toContain("***"); + expect(invitation.orderStatus).toBeNull(); + expect(invitation.deliveryAgreementStatus).toBeNull(); + }); +}); diff --git a/volumes/functions/_shared/delivery-invitations.ts b/volumes/functions/_shared/delivery-invitations.ts new file mode 100644 index 0000000..70e7ade --- /dev/null +++ b/volumes/functions/_shared/delivery-invitations.ts @@ -0,0 +1,313 @@ +import { + maskCustomerName, + maskPhoneNumber, +} from "./security.ts"; + +export type DeliveryInvitationAction = + | "create_delivery_invitation" + | "send_delivery_offer" + | "send_delivery_reminder" + | "request_new_link" + | "confirm_delivery_choice" + | "transfer_to_logistics" + | "mark_paid_storage" + | "mark_delivered"; + +export type DeliveryInvitationPublicState = + | "awaiting_choice" + | "opened" + | "reminder_sent" + | "transferred_to_logistics" + | "paid_storage" + | "delivered" + | "agreed" + | "default"; + +export const DEFAULT_AVAILABLE_SLOTS = ["Первая половина дня", "Вторая половина дня"]; + +export const getOrderUpdateForDeliveryInvitationAction = (action: DeliveryInvitationAction) => { + switch (action) { + case "create_delivery_invitation": + case "send_delivery_offer": + case "send_delivery_reminder": + case "request_new_link": + return { + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }; + case "confirm_delivery_choice": + return { + status: "Доставка согласована", + deliveryAgreementStatus: "Подтверждено клиентом", + }; + case "transfer_to_logistics": + return { + status: "Передан логисту", + deliveryAgreementStatus: "Нет ответа", + }; + case "mark_paid_storage": + return { + status: "Платное хранение", + deliveryAgreementStatus: "Нет ответа", + }; + case "mark_delivered": + return { + status: "Доставлен", + deliveryAgreementStatus: "Подтверждено клиентом", + }; + default: + return null; + } +}; + +export const getClientInvitationStateFromOrderStatus = ( + status: string, +): DeliveryInvitationPublicState => { + switch (status) { + case "Ожидает ответа клиента": + return "awaiting_choice"; + case "Ожидает согласования доставки": + return "opened"; + case "Напоминание отправлено": + case "Переход отправлен": + return "reminder_sent"; + case "Передан логисту": + return "transferred_to_logistics"; + case "Платное хранение": + return "paid_storage"; + case "Доставлен": + return "delivered"; + case "Доставка согласована": + return "agreed"; + default: + return "default"; + } +}; + +export const getClientInvitationStateFromOrderGroupStatus = ( + deliveryStatus: string | null | undefined, + invitationState: string | null | undefined, +): DeliveryInvitationPublicState => { + if (deliveryStatus === "agreed") { + return "agreed"; + } + + if (deliveryStatus === "delivered") { + return "delivered"; + } + + if (["awaiting_choice", "opened", "reminder_sent"].includes(String(invitationState || ""))) { + return invitationState as DeliveryInvitationPublicState; + } + + return "default"; +}; + +export const isActiveInvitationState = (state: DeliveryInvitationPublicState) => + state === "awaiting_choice" || state === "opened" || state === "reminder_sent"; + +export const generateInvitationToken = () => crypto.randomUUID().replaceAll("-", ""); + +export const hashInvitationToken = async (token: string) => { + const bytes = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { + const slots = availableSlots?.map((slot) => slot.trim()).filter(Boolean) || []; + return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; +}; + +export const buildDefaultDatedAvailableSlots = (now = new Date()) => { + const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10); + const addDays = (date: Date, days: number) => { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; + }; + + const firstDay = formatIsoDate(addDays(now, 1)); + const secondDay = formatIsoDate(addDays(now, 2)); + + return [ + `${firstDay}, Первая половина дня`, + `${firstDay}, Вторая половина дня`, + `${secondDay}, Первая половина дня`, + `${secondDay}, Вторая половина дня`, + ]; +}; + +export const resolvePublicAppUrl = ( + request: Request, + fallbackEnv?: string, +) => { + const origin = request.headers.get("origin") || request.headers.get("referer") || ""; + const envValue = + fallbackEnv || + (typeof Deno !== "undefined" ? Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") : ""); + return (envValue || origin || "").replace(/\/$/, ""); +}; + +export const buildInvitationUrl = (baseUrl: string, token: string) => + `${baseUrl.replace(/\/$/, "")}/delivery/${token}`; + +export type DeliveryInvitationRecord = { + id?: string; + order_id?: string | null; + order_group_id?: string | null; + token_hash: string; + state: string; + order_number?: string | null; + customer_name?: string | null; + customer_phone?: string | null; + customer_messenger?: string | null; + available_slots?: string[] | null; + expires_at?: string | null; + revoked_at?: string | null; + delivery_date?: string | null; + delivery_time?: string | null; + sent_at?: string | null; + opened_at?: string | null; + confirmed_at?: string | null; + logistics_transferred_at?: string | null; + paid_storage_at?: string | null; + delivered_at?: string | null; + updated_at?: string | null; +}; + +export type OrderGroupInvitationSource = { + id: string; + group_key?: string | null; + customer?: { + name?: string | null; + phone?: string | null; + date?: string | null; + } | null; + customer_name?: string | null; + customer_phone?: string | null; + customer_date?: string | null; + order_numbers?: string[] | null; + delivery_status?: string | null; + delivery_link?: string | null; + source_orders?: unknown[] | null; +}; +export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => { + if (invitation.revoked_at) { + return true; + } + + if (!invitation.expires_at) { + return false; + } + + return new Date(invitation.expires_at).getTime() <= now.getTime(); +}; + +const parseGroupKey = (groupKey?: string | null) => { + const [phone = "", date = ""] = String(groupKey || "").split("|"); + return { + phone: phone.trim(), + date: date.trim(), + }; +}; + +const extractOrderItemsFromSourceOrders = (sourceOrders: unknown): Array<{ name: string; quantity: string; items?: unknown[] }> => { + if (!Array.isArray(sourceOrders) || sourceOrders.length === 0) { + return []; + } + + const items: Array<{ name: string; quantity: string; items?: unknown[] }> = []; + + for (const source of sourceOrders) { + if (!source || typeof source !== "object") { + continue; + } + + const record = source as Record; + const nom = typeof record.nom === "string" ? record.nom : typeof record.name === "string" ? record.name : ""; + const orderList = Array.isArray(record.orderList) ? record.orderList : Array.isArray(record.items) ? record.items : []; + + if (orderList.length > 0) { + items.push({ + name: nom || "Позиция", + quantity: "", + items: orderList.map((item: unknown) => { + if (!item || typeof item !== "object") { + return { name: String(item), quantity: "" }; + } + const row = item as Record; + return { + name: String(row.product_name || row.name || row.title || ""), + quantity: String(row.product_quantity || row.quantity || row.count || row.amount || ""), + }; + }), + }); + } else if (nom) { + items.push({ name: nom, quantity: "" }); + } + } + + return items; +}; + +export const buildPublicOrderGroupInvitationView = ( + invitation: DeliveryInvitationRecord, + group: OrderGroupInvitationSource, +) => { + const parsedKey = parseGroupKey(group.group_key); + const customerName = group.customer_name || group.customer?.name || invitation.customer_name || null; + const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null; + const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : []; + + const orderItemsFromSource = extractOrderItemsFromSourceOrders(group.source_orders); + const orderItems = orderItemsFromSource.length > 0 + ? orderItemsFromSource + : orderNumbers.map((number) => ({ name: number, quantity: "" })); + + return { + orderId: invitation.order_group_id || group.id, + orderGroupId: invitation.order_group_id || group.id, + state: invitation.state, + token: "", + orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null, + customerName: maskCustomerName(customerName), + customerPhone: maskPhoneNumber(customerPhone), + orderItems, + availableSlots: invitation.available_slots || [], + deliveryDate: invitation.delivery_date || null, + deliveryTime: invitation.delivery_time || null, + orderStatus: null, + deliveryAgreementStatus: null, + }; +}; + +export const buildPublicInvitationView = ( + invitation: DeliveryInvitationRecord, + order: { + order_number?: string | null; + customer?: { name?: string | null; phone?: string | null; items?: unknown }; + status?: string | null; + delivery_agreement_status?: string | null; + }, +) => { + const availableSlots = invitation.available_slots || []; + const orderItems = Array.isArray(order.customer?.items) + ? order.customer?.items + : []; + + return { + orderId: invitation.order_id, + state: invitation.state, + token: "", + orderNumber: order.order_number || invitation.order_number || null, + customerName: maskCustomerName(order.customer?.name || invitation.customer_name || null), + customerPhone: maskPhoneNumber(order.customer?.phone || invitation.customer_phone || null), + orderItems, + availableSlots, + deliveryDate: invitation.delivery_date || null, + deliveryTime: invitation.delivery_time || null, + orderStatus: null, + deliveryAgreementStatus: null, + }; +}; diff --git a/volumes/functions/_shared/integration-events.ts b/volumes/functions/_shared/integration-events.ts new file mode 100644 index 0000000..8945f24 --- /dev/null +++ b/volumes/functions/_shared/integration-events.ts @@ -0,0 +1,30 @@ +type IntegrationEventPayload = { + order_id?: string | null; + event_type: string; + direction?: "inbound" | "outbound" | "internal"; + source?: string; + status?: string; + payload?: Record; + error_message?: string | null; +}; + +export const insertIntegrationEvent = async ( + supabase: { + from: (table: string) => { + insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>; + }; + }, + payload: IntegrationEventPayload, +) => { + const { error } = await supabase.from("integration_events").insert({ + direction: "internal", + source: "supabase-function", + status: "success", + payload: {}, + ...payload, + }); + + if (error) { + throw error; + } +}; diff --git a/volumes/functions/_shared/security.ts b/volumes/functions/_shared/security.ts new file mode 100644 index 0000000..12682e1 --- /dev/null +++ b/volumes/functions/_shared/security.ts @@ -0,0 +1,172 @@ +import { createClient } from 'npm:@supabase/supabase-js@2'; + +const ALLOWED_ORIGINS = [ + 'https://supa.supersamsev.ru', + 'https://dost.supersamsev.ru', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:3000', + 'https://supasevdev.mkn8n.ru', +]; + +export function createServiceClient() { + const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''; + return createClient(supabaseUrl, serviceRoleKey); +} + +export function getClientIp(request: Request): string { + const xff = request.headers.get('x-forwarded-for'); + if (xff) return xff.split(',')[0].trim(); + return request.headers.get('x-real-ip') || 'unknown'; +} + +export function getCorsHeaders(request: Request, _access: 'public' | 'private') { + const origin = request.headers.get('origin') || ''; + if (!origin) { + return { + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0], + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', + 'Access-Control-Max-Age': '86400', + }; + } + const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o)); + if (!allowed) return null; + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', + 'Access-Control-Max-Age': '86400', + }; +} + +export function preflightResponse(request: Request, access: 'public' | 'private') { + const corsHeaders = getCorsHeaders(request, access); + if (!corsHeaders) { + return new Response('Origin not allowed', { status: 403 }); + } + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (corsHeaders) Object.assign(headers, corsHeaders); + return new Response(JSON.stringify(body), { status, headers }); +} + +export async function hashText(text: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +interface JsonBodyResult { + body: T; +} + +export async function readJsonBody(request: Request, options?: { maxBytes?: number }): Promise> { + const maxBytes = options?.maxBytes ?? 1024 * 1024; + const reader = request.body?.getReader(); + if (!reader) throw new Error('No body'); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.length; + if (totalBytes > maxBytes) { + reader.cancel(); + throw Object.assign(new Error('Request body too large'), { status: 413 }); + } + chunks.push(value); + } + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + const text = new TextDecoder().decode(combined); + const body = JSON.parse(text) as T; + return { body }; +} + +interface RateLimitOptions { + scope: string; + key: string; + maxCount: number; + windowSeconds: number; + blockSeconds: number; +} + +class RateLimitError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { + const { scope, key, maxCount, windowSeconds, blockSeconds } = options; + const tableName = 'rate_limits'; + const now = new Date(); + + const { data: blocked } = await supabase + .from(tableName) + .select('blocked_until') + .eq('scope', scope) + .eq('rate_key', key) + .gt('blocked_until', now.toISOString()) + .limit(1); + + if (blocked && blocked.length > 0) { + throw new RateLimitError('Too many requests. Please try again later.', 429); + } + + const windowStart = new Date(now.getTime() - windowSeconds * 1000); + const { data: recent, error } = await supabase + .from(tableName) + .select('id, count') + .eq('scope', scope) + .eq('rate_key', key) + .gte('window_start', windowStart.toISOString()); + + if (error) { + console.error('Rate limit check error:', error); + return; + } + + const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0; + + if (totalCount >= maxCount) { + const blockedUntil = new Date(now.getTime() + blockSeconds * 1000); + await supabase + .from(tableName) + .update({ blocked_until: blockedUntil.toISOString() }) + .eq('scope', scope) + .eq('rate_key', key) + .gte('window_start', windowStart.toISOString()); + throw new RateLimitError('Too many requests. Please try again later.', 429); + } + + const existingRow = recent?.[0]; + if (existingRow) { + await supabase + .from(tableName) + .update({ count: (existingRow as { count: number }).count + 1 }) + .eq('id', (existingRow as { id: string }).id); + } else { + await supabase.from(tableName).insert({ + scope, + rate_key: key, + window_start: now.toISOString(), + count: 1, + blocked_until: null, + }); + } +} \ No newline at end of file diff --git a/volumes/functions/_shared/workflow.test.ts b/volumes/functions/_shared/workflow.test.ts new file mode 100644 index 0000000..ed76db2 --- /dev/null +++ b/volumes/functions/_shared/workflow.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + getOrderUpdateForInboundAction, + getOrderUpdateForOutboundDispatch, +} from "./workflow"; + +describe("chatbot workflow mapping", () => { + it("maps confirm delivery to agreed delivery statuses", () => { + expect(getOrderUpdateForInboundAction("confirm_delivery")).toEqual({ + status: "Доставка согласована", + deliveryAgreementStatus: "Подтверждено клиентом", + }); + }); + + it("maps reschedule request to waiting coordination statuses", () => { + expect(getOrderUpdateForInboundAction("reschedule")).toEqual({ + status: "Ожидает согласования доставки", + deliveryAgreementStatus: "Перенос запрошен", + }); + }); + + it("marks outbound delivery offer as awaiting client response", () => { + expect(getOrderUpdateForOutboundDispatch("send_delivery_offer")).toEqual({ + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }); + }); + + it("keeps reminder dispatch in the same awaiting response state", () => { + expect(getOrderUpdateForOutboundDispatch("send_delivery_reminder")).toEqual({ + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }); + }); +}); diff --git a/volumes/functions/_shared/workflow.ts b/volumes/functions/_shared/workflow.ts new file mode 100644 index 0000000..a05b025 --- /dev/null +++ b/volumes/functions/_shared/workflow.ts @@ -0,0 +1,44 @@ +import { getOrderUpdateForDeliveryInvitationAction } from "./delivery-invitations.ts"; + +export type InboundWorkflowAction = + | "confirm_delivery" + | "reschedule" + | "cancel_delivery" + | "unknown"; + +export type OutboundWorkflowAction = + | "send_delivery_offer" + | "send_delivery_reminder" + | "custom_message"; + +export const getOrderUpdateForInboundAction = (action: InboundWorkflowAction) => { + switch (action) { + case "confirm_delivery": + return { + status: "Доставка согласована", + deliveryAgreementStatus: "Подтверждено клиентом", + }; + case "reschedule": + return { + status: "Ожидает согласования доставки", + deliveryAgreementStatus: "Перенос запрошен", + }; + case "cancel_delivery": + return { + status: "Проблема доставки", + deliveryAgreementStatus: "Нет ответа", + }; + default: + return null; + } +}; + +export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction) => { + switch (action) { + case "send_delivery_offer": + case "send_delivery_reminder": + return getOrderUpdateForDeliveryInvitationAction(action); + default: + return null; + } +}; diff --git a/volumes/functions/chatbot-webhook/index.ts b/volumes/functions/chatbot-webhook/index.ts new file mode 100644 index 0000000..3acd703 --- /dev/null +++ b/volumes/functions/chatbot-webhook/index.ts @@ -0,0 +1,141 @@ +import { + channelFromProvider, + createServiceClient, + json, + normalizeIncomingEvent, + orderUpdateByAction, + type ProviderName, +} from "../_shared/chatbot.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 64 * 1024; + +const allowedProviders = new Set(["telegram", "vk", "messenger_max"]); + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "webhook"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403); + } + + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "webhook") || {}; + + try { + const url = new URL(request.url); + const provider = url.searchParams.get("provider") as ProviderName | null; + if (!provider || !allowedProviders.has(provider)) { + return json({ error: "provider is required" }, 400); + } + + const { body, rawBody } = await readJsonBody>(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { + rawBody, + secretEnvNames: [ + `CHATBOT_WEBHOOK_SECRET_${provider.toUpperCase()}`, + "CHATBOT_WEBHOOK_SECRET", + ], + tokenEnvNames: [ + `CHATBOT_WEBHOOK_TOKEN_${provider.toUpperCase()}`, + "CHATBOT_WEBHOOK_TOKEN", + ], + }); + + const event = normalizeIncomingEvent(provider, body); + if (!event.orderId) { + return json({ error: "order_id is required" }, 400); + } + + const supabase = createServiceClient(); + const rateKey = event.externalMessageId || (await hashText(`${provider}:${getClientIp(request)}:${event.text}`)); + + await requireRateLimit(supabase, { + scope: `webhook-${provider}`, + key: rateKey, + maxCount: 60, + windowSeconds: 60, + blockSeconds: 300, + }); + + const orderUpdate = orderUpdateByAction(event.action); + + const messagePayload = { + order_id: event.orderId, + sender_name: "chatbot-webhook", + sender_type: event.senderType, + channel: channelFromProvider(event.provider), + text: event.text || `Inbound ${event.provider} event`, + external_message_id: event.externalMessageId, + payload: event.payload, + }; + + const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload); + if (messageError && messageError.code !== "23505") { + throw messageError; + } + + if (orderUpdate) { + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", event.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate.status, + delivery_agreement_status: orderUpdate.deliveryAgreementStatus, + }) + .eq("id", event.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: event.orderId, + action: `Webhook ${provider}: ${event.action}`, + old_status: currentOrder.status, + new_status: orderUpdate.status, + metadata: { + ...event.payload, + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus, + }, + }); + + if (historyError) { + throw historyError; + } + } + + return new Response(JSON.stringify({ ok: true }), { + headers: corsHeaders, + }); + } catch (error) { + return json( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + ); + } +}); diff --git a/volumes/functions/confirm-delivery-choice/index.ts b/volumes/functions/confirm-delivery-choice/index.ts new file mode 100644 index 0000000..f105c99 --- /dev/null +++ b/volumes/functions/confirm-delivery-choice/index.ts @@ -0,0 +1,360 @@ +import { + getOrderUpdateForDeliveryInvitationAction, + hashInvitationToken, + 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 { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, + requireSameOrigin, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +type ConfirmBody = { + token?: string; + deliveryDate?: string; + deliveryTime?: string; +}; + +const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +const resolveRequestedSlot = ( + invitation: { + delivery_date?: string | null; + delivery_time?: string | null; + available_slots?: string[] | null; + }, + body: ConfirmBody, +) => { + const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); + const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); + + if (!deliveryDate || !deliveryTime || !isValidDate(deliveryDate)) { + return null; + } + + const slotLabel = `${deliveryDate}, ${deliveryTime}`; + const availableSlots = invitation.available_slots || []; + + if (availableSlots.length > 0 && !availableSlots.includes(slotLabel)) { + return null; + } + + return { deliveryDate, deliveryTime }; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + 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, + }); + + if (!body.token) { + 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)); + + await requireRateLimit(supabase, { + scope: "invitation-confirm", + key: `${ipHash}:${tokenHash.slice(0, 16)}`, + maxCount: 5, + windowSeconds: 600, + blockSeconds: 3600, + }); + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .select("*") + .eq("token_hash", tokenHash) + .single(); + + if (invitationError) { + if (invitationError.code === "PGRST116") { + return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders); + } + + throw invitationError; + } + + if (isInvitationExpired(invitation)) { + return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); + } + + if (invitation.order_group_id) { + const { data: currentGroup, error: groupError } = await supabase + .from("order_groups") + .select("id, delivery_status") + .eq("id", invitation.order_group_id) + .single(); + + if (groupError) { + throw groupError; + } + + if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") { + return jsonResponse( + { + ok: false, + error: "Invitation is no longer active", + }, + 409, + corsHeaders, + ); + } + + const requestedSlot = resolveRequestedSlot(invitation, body); + if (!requestedSlot) { + return jsonResponse( + { + ok: false, + error: "Selected slot is not available", + }, + 422, + corsHeaders, + ); + } + + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + confirmed_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + + const { error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_status: "agreed", + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }) + .eq("id", invitation.order_group_id); + + if (groupUpdateError) { + throw groupUpdateError; + } + + // Log: client confirmed delivery choice + await supabase.from("action_logs").insert({ + order_group_id: invitation.order_group_id, + action: "client_confirmed", + old_value: currentGroup.delivery_status, + new_value: "agreed", + details: { + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + source: "auto", + }, + }); + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "delivery_choice_confirmed", + direction: "inbound", + status: "success", + payload: { + order_group_id: invitation.order_group_id, + delivery_invitation_id: invitation.id, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + }, + }); + + return jsonResponse( + { + ok: true, + orderGroupId: invitation.order_group_id, + deliveryStatus: "agreed", + }, + 200, + corsHeaders, + ); + } + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", invitation.order_id) + .single(); + + if (orderError) { + throw orderError; + } + + if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) { + return jsonResponse( + { + ok: false, + error: "Invitation is no longer active", + }, + 409, + corsHeaders, + ); + } + + const requestedSlot = resolveRequestedSlot(invitation, body); + if (!requestedSlot) { + return jsonResponse( + { + ok: false, + error: "Selected slot is not available", + }, + 422, + corsHeaders, + ); + } + + const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice"); + + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + confirmed_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + + const { error: orderUpdateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + }) + .eq("id", invitation.order_id); + + if (orderUpdateError) { + throw orderUpdateError; + } + + const { error: slotError } = await supabase.from("delivery_slots").insert({ + order_id: invitation.order_id, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + logistician_id: null, + status: "confirmed_by_client", + }); + + if (slotError) { + throw slotError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: invitation.order_id, + action: "Подтверждение выбора доставки клиентом", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: invitation.order_id, + event_type: "delivery_choice_confirmed", + direction: "inbound", + status: "success", + payload: { + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + }, + }); + + return jsonResponse( + { + ok: true, + orderId: invitation.order_id, + status: orderUpdate?.status, + deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file diff --git a/volumes/functions/create-delivery-invitation/index.ts b/volumes/functions/create-delivery-invitation/index.ts new file mode 100644 index 0000000..c19c3fb --- /dev/null +++ b/volumes/functions/create-delivery-invitation/index.ts @@ -0,0 +1,409 @@ +import { + buildDefaultDatedAvailableSlots, + buildInvitationUrl, + generateInvitationToken, + getOrderUpdateForDeliveryInvitationAction, + hashInvitationToken, + normalizeAvailableSlots, + resolvePublicAppUrl, +} from "../_shared/delivery-invitations.ts"; +import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getClientIp, + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 16 * 1024; +const MAX_SLOTS = 14; + +type CreateInvitationBody = { + orderId?: string; + orderGroupId?: string; + orderNumber?: string; + customerName?: string; + customerPhone?: string; + customerMessenger?: string; + availableSlots?: string[]; + source?: string; +}; + +const parseGroupKey = (groupKey?: string | null) => { + const [phone = "", date = ""] = String(groupKey || "").split("|"); + return { + phone: phone.trim(), + date: date.trim(), + }; +}; + +const resolveRequiredPublicAppUrl = (request: Request) => { + const publicBaseUrl = resolvePublicAppUrl(request); + if (!publicBaseUrl) { + throw new Error("PUBLIC_APP_URL is not configured"); + } + + return publicBaseUrl; +}; + +const createOrderGroupInvitation = async ({ + body, + request, + corsHeaders, +}: { + body: CreateInvitationBody; + request: Request; + corsHeaders: HeadersInit; +}) => { + const supabase = createServiceClient(); + const orderGroupId = String(body.orderGroupId || "").trim(); + + await requireRateLimit(supabase, { + scope: "delivery-invitation-create", + key: orderGroupId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: group, error: groupError } = await supabase + .from("order_groups") + .select("*") + .eq("id", orderGroupId) + .single(); + + if (groupError) { + throw groupError; + } + + const parsedKey = parseGroupKey(group.group_key); + const customerName = body.customerName || group.customer_name || group.customer?.name || null; + const customerPhone = body.customerPhone || group.customer_phone || group.customer?.phone || parsedKey.phone || null; + const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : []; + const orderNumber = body.orderNumber || group.group_key || orderNumbers[0] || null; + + if (!customerPhone) { + return jsonResponse({ ok: false, error: "customerPhone is required" }, 400, corsHeaders); + } + + const { data: existingInvitation, error: existingInvitationError } = await supabase + .from("delivery_invitations") + .select("id, state") + .eq("order_group_id", orderGroupId) + .in("state", ["awaiting_choice", "opened", "reminder_sent"]) + .maybeSingle(); + + if (existingInvitationError) { + throw existingInvitationError; + } + + if (existingInvitation) { + if (!group.delivery_link) { + const { error: revokeInvitationError } = await supabase + .from("delivery_invitations") + .update({ + state: "default", + revoked_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", existingInvitation.id); + + if (revokeInvitationError) { + throw revokeInvitationError; + } + } else { + return jsonResponse( + { + ok: true, + alreadyStarted: true, + invitation: { + id: existingInvitation.id, + orderGroupId, + state: existingInvitation.state, + url: group.delivery_link || null, + }, + }, + 200, + corsHeaders, + ); + } + } + + if (existingInvitation && !group.delivery_link) { + const { error: clearBrokenLinkError } = await supabase + .from("order_groups") + .update({ + delivery_invitation_id: null, + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId); + + if (clearBrokenLinkError) { + throw clearBrokenLinkError; + } + } + + const token = generateInvitationToken(); + const tokenHash = await hashInvitationToken(token); + const publicBaseUrl = resolveRequiredPublicAppUrl(request); + const url = buildInvitationUrl(publicBaseUrl, token); + const availableSlots = body.availableSlots?.length + ? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS) + : buildDefaultDatedAvailableSlots(); + + const invitationPayload = { + order_id: null, + order_group_id: orderGroupId, + token_hash: tokenHash, + state: "awaiting_choice", + order_number: orderNumber, + customer_name: customerName, + customer_phone: customerPhone, + customer_messenger: body.customerMessenger || null, + available_slots: availableSlots, + expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + sent_at: null, + }; + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .insert(invitationPayload) + .select("id") + .single(); + + if (invitationError) { + throw invitationError; + } + + const { error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_invitation_id: invitation.id, + delivery_link: url, + notification_status: "link_ready", + next_notification_check_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId); + + if (groupUpdateError) { + throw groupUpdateError; + } + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "delivery_invitation_created", + direction: "outbound", + status: "success", + payload: { + order_group_id: orderGroupId, + delivery_invitation_id: invitation.id, + token_hash: tokenHash, + available_slots: availableSlots, + }, + }); + + return jsonResponse( + { + ok: true, + invitation: { + id: invitation.id, + orderGroupId, + token, + url, + state: "awaiting_choice", + availableSlots, + }, + }, + 200, + corsHeaders, + ); +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "integration") || {}; + + try { + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + const auth = await verifyInternalRequest(request, rawBody, { + rawBody, + allowedClockSkewSeconds: 300, + }); + + if (!body.orderId && !body.orderGroupId) { + return jsonResponse({ error: "orderId or orderGroupId is required" }, 400, corsHeaders); + } + + if (body.orderGroupId) { + return await createOrderGroupInvitation({ body, request, corsHeaders }); + } + + const orderId = body.orderId as string; + const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "delivery-invitation-create", + key: orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const token = generateInvitationToken(); + const tokenHash = await hashInvitationToken(token); + const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation"); + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at") + .eq("id", orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const { data: existingInvitation, error: existingInvitationError } = await supabase + .from("delivery_invitations") + .select( + "id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at", + ) + .eq("order_id", orderId) + .maybeSingle(); + + if (existingInvitationError) { + throw existingInvitationError; + } + + if (currentOrder.delivery_flow_started_at || existingInvitation) { + return jsonResponse( + { + ok: true, + alreadyStarted: true, + invitation: existingInvitation + ? { + orderId, + state: existingInvitation.state, + availableSlots: existingInvitation.available_slots || [], + orderNumber: existingInvitation.order_number || body.orderNumber || null, + customerName: existingInvitation.customer_name || body.customerName || null, + customerPhone: existingInvitation.customer_phone || body.customerPhone || null, + customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null, + } + : { + orderId, + state: "awaiting_choice", + }, + }, + 200, + corsHeaders, + ); + } + + const invitationPayload = { + order_id: orderId, + token_hash: tokenHash, + state: "awaiting_choice", + order_number: body.orderNumber || null, + customer_name: body.customerName || null, + customer_phone: body.customerPhone || null, + customer_messenger: body.customerMessenger || null, + available_slots: normalizeAvailableSlots(body.availableSlots), + expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + sent_at: new Date().toISOString(), + }; + + const { error: invitationError } = await supabase.from("delivery_invitations").insert(invitationPayload); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + ready_for_delivery_at: currentOrder.ready_for_delivery_at || new Date().toISOString(), + delivery_flow_started_at: new Date().toISOString(), + delivery_flow_source: body.source || "n8n", + }) + .eq("id", orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: orderId, + action: "Создание приглашения доставки", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + channel: channelFromProvider("telegram"), + auth: auth.authenticatedBy, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: orderId, + event_type: "delivery_invitation_created", + direction: "outbound", + status: "success", + payload: { + token_hash: tokenHash, + available_slots: invitationPayload.available_slots, + }, + }); + + const publicBaseUrl = resolveRequiredPublicAppUrl(request); + + return jsonResponse( + { + ok: true, + invitation: { + orderId, + token, + url: buildInvitationUrl(publicBaseUrl, token), + state: "awaiting_choice", + availableSlots: invitationPayload.available_slots, + }, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/get-delivery-invitation/index.ts b/volumes/functions/get-delivery-invitation/index.ts new file mode 100644 index 0000000..5595b95 --- /dev/null +++ b/volumes/functions/get-delivery-invitation/index.ts @@ -0,0 +1,191 @@ +import { + buildPublicOrderGroupInvitationView, + buildPublicInvitationView, + getClientInvitationStateFromOrderGroupStatus, + getClientInvitationStateFromOrderStatus, + hashInvitationToken, + isActiveInvitationState, + isInvitationExpired, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; +import { isValidUuid } from "../_shared/security.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +type InvitationBody = { + token?: string; +}; + +const getTokenFromRequest = async (request: Request) => { + if (request.method === "GET") { + return new URL(request.url).searchParams.get("token") || ""; + } + + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + return String(body.token || "").trim(); +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (!["GET", "POST"].includes(request.method)) { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const token = await getTokenFromRequest(request); + if (!token) { + return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); + } + + const tokenHash = await hashInvitationToken(token); + const supabase = createServiceClient(); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "invitation-get", + key: `${ipHash}:${tokenHash.slice(0, 16)}`, + maxCount: 30, + windowSeconds: 600, + }); + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .select("*") + .eq("token_hash", tokenHash) + .single(); + + if (invitationError) { + if (invitationError.code === "PGRST116") { + return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders); + } + + throw invitationError; + } + + if (isInvitationExpired(invitation)) { + return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); + } + + if (invitation.order_group_id) { + const { data: group, error: groupError } = await supabase + .from("order_groups") + .select("*") + .eq("id", invitation.order_group_id) + .single(); + + if (groupError) { + throw groupError; + } + + const publicState = getClientInvitationStateFromOrderGroupStatus( + group.delivery_status, + invitation.state, + ); + + await supabase + .from("delivery_invitations") + .update({ + opened_at: isActiveInvitationState(publicState) && !invitation.opened_at + ? new Date().toISOString() + : invitation.opened_at, + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + const invitationView = buildPublicOrderGroupInvitationView(invitation, group); + + return jsonResponse( + { + ok: true, + invitation: { + ...invitationView, + token, + state: publicState, + }, + }, + 200, + corsHeaders, + ); + } + + const { data: order, error: orderError } = await supabase + .from("orders") + .select("id, order_number, status, delivery_agreement_status, customer") + .eq("id", invitation.order_id) + .single(); + + if (orderError) { + throw orderError; + } + + const publicState = getClientInvitationStateFromOrderStatus(order.status); + + if (isActiveInvitationState(publicState) && !invitation.opened_at) { + await supabase + .from("delivery_invitations") + .update({ + opened_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + } else { + await supabase + .from("delivery_invitations") + .update({ + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + } + + const invitationView = buildPublicInvitationView(invitation, order); + + return jsonResponse( + { + ok: true, + invitation: { + ...invitationView, + token, + state: publicState, + }, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/import_map.json b/volumes/functions/import_map.json new file mode 100644 index 0000000..6f75758 --- /dev/null +++ b/volumes/functions/import_map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8" + } +} diff --git a/volumes/functions/main/index.ts b/volumes/functions/main/index.ts new file mode 100644 index 0000000..cf93be4 --- /dev/null +++ b/volumes/functions/main/index.ts @@ -0,0 +1,168 @@ +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// Create JWKS for ES256/RS256 tokens (newer tokens) +let SUPABASE_JWT_KEYS: ReturnType | null = null +if (SUPABASE_URL) { + try { + SUPABASE_JWT_KEYS = jose.createRemoteJWKSet( + new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL) + ) + } catch (e) { + console.error('Failed to fetch JWKS from SUPABASE_URL:', e) + } +} + +/** + * Extract JWT token from Authorization header + * + * Parses the Authorization header to extract the Bearer token. + * Expects format: "Bearer " + * + * @param req - The HTTP request object + * @returns The JWT token string + * @throws Error if Authorization header is missing or malformed + */ +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function isValidLegacyJWT(jwt: string): Promise { + if (!JWT_SECRET) { + console.error('JWT_SECRET not available for HS256 token verification') + return false + } + + const encoder = new TextEncoder(); + const secretKey = encoder.encode(JWT_SECRET) + + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error('Symmetric Legacy JWT verification error', e); + return false; + } + return true; +} + +async function isValidJWT(jwt: string): Promise { + if (!SUPABASE_JWT_KEYS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + } catch (e) { + console.error('Asymmetric JWT verification error', e); + return false + } + + return true; +} + +/** + * Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms + * + * This function automatically detects the algorithm used in the token and applies + * the appropriate verification method: + * - HS256: Uses JWT_SECRET (symmetric key) + * - ES256/RS256: Uses JWKS endpoint (asymmetric public keys) + * + * This fix ensures compatibility with both legacy tokens and newer asymmetric tokens, + * resolving the "Key for the ES256 algorithm must be of type CryptoKey" error. + * + * @param jwt - The JWT token string to verify + * @returns Promise resolving to true if verification succeeds, false otherwise + */ +async function isValidHybridJWT(jwt: string): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) + + if (jwtAlgorithm === 'HS256') { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) + + return await isValidLegacyJWT(jwt) + } + + if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { + return await isValidJWT(jwt) + } + + return false; +} + +Deno.serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await isValidHybridJWT(token); + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = "/home/deno/functions/import_map.json" + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/volumes/functions/report-delivery-result/index.ts b/volumes/functions/report-delivery-result/index.ts new file mode 100644 index 0000000..c41d7b5 --- /dev/null +++ b/volumes/functions/report-delivery-result/index.ts @@ -0,0 +1,158 @@ +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 { + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 16 * 1024; + +type ReportBody = { + orderId?: string; + result?: "delivered" | "problem"; + note?: string; + payload?: Record; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403); + } + + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "integration") || {}; + + try { + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { rawBody }); + + if (!body.orderId) { + 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", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const isDelivered = body.result === "delivered"; + const action = isDelivered ? "mark_delivered" : "mark_paid_storage"; + const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action); + const nextStatus = isDelivered ? orderUpdate?.status || "Доставлен" : "Проблема доставки"; + + const { error: invitationError } = await supabase + .from("delivery_invitations") + .update({ + state: isDelivered ? "delivered" : "paid_storage", + ...(isDelivered ? { delivered_at: new Date().toISOString() } : { paid_storage_at: new Date().toISOString() }), + }) + .eq("order_id", body.orderId); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: nextStatus, + delivery_agreement_status: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: isDelivered ? "Подтверждение доставки" : "Фиксация проблемы доставки", + old_status: currentOrder.status, + new_status: isDelivered ? "Доставлен" : "Проблема доставки", + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + payload: body.payload || {}, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: body.orderId, + event_type: isDelivered ? "delivery_result_delivered" : "delivery_result_problem", + direction: "internal", + status: "success", + payload: { + result: body.result || null, + note: body.note || null, + payload: body.payload || {}, + }, + }); + + return jsonResponse( + { + ok: true, + orderId: body.orderId, + status: nextStatus, + deliveryAgreementStatus: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + workflowStatus: nextStatus, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/request-otp/index.ts b/volumes/functions/request-otp/index.ts new file mode 100644 index 0000000..f28569f --- /dev/null +++ b/volumes/functions/request-otp/index.ts @@ -0,0 +1,126 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +function generateOtp(): string { + const digits = "0123456789"; + let otp = ""; + const arr = new Uint8Array(6); + crypto.getRandomValues(arr); + for (let i = 0; i < 6; i++) { + otp += digits[arr[i] % digits.length]; + } + return otp; +} + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody<{ email?: string }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + const email = String(body.email || "").trim().toLowerCase(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); + } + + const supabase = createServiceClient(); + const emailHash = await hashText(email); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "otp-request", + key: `${ipHash}:${emailHash}`, + maxCount: 3, + windowSeconds: 600, + blockSeconds: 1800, + }); + + // Check if user exists in our users table + const { data: users, error: userError } = await supabase + .from("users") + .select("id, name, roles(name)") + .eq("email", email) + .limit(1); + + if (userError || !users || users.length === 0) { + return jsonResponse({ ok: false, error: "Email не найден в системе. Обратитесь к администратору." }, 400, corsHeaders); + } + + const user = users[0]; + const userName = user.name || null; + const userRole = user.roles?.name || null; + + // Invalidate previous unverified OTPs for this email + await supabase + .from("login_otps") + .delete() + .eq("email", email) + .eq("verified", false); + + // Generate OTP + const otp = generateOtp(); + const otpCodeHash = await hashText(otp); + const clientIp = getClientIp(request); + const userAgent = request.headers.get("user-agent") || null; + + // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n + // n8n will clear otp_code after sending SMS + const { error: insertError } = await supabase.from("login_otps").insert({ + email, + name: userName, + role: userRole, + otp_code: otp, + otp_code_hash: otpCodeHash, + ip_address: clientIp, + user_agent: userAgent, + verified: false, + }); + + if (insertError) { + console.error("Failed to insert OTP:", insertError); + return jsonResponse({ ok: false, error: "Failed to generate OTP" }, 500, corsHeaders); + } + + return jsonResponse({ ok: true }, 200, corsHeaders); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/send-chatbot-message/index.ts b/volumes/functions/send-chatbot-message/index.ts new file mode 100644 index 0000000..e7d3dc8 --- /dev/null +++ b/volumes/functions/send-chatbot-message/index.ts @@ -0,0 +1,152 @@ +import { + channelFromProvider, + createServiceClient, + json, + type ProviderName, +} from "../_shared/chatbot.ts"; +import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts"; +import { + getCorsHeaders, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; + +const providerTokens: Record = { + telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"), + vk: Deno.env.get("VK_BOT_TOKEN"), + messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"), +}; + +const MAX_BODY_BYTES = 16 * 1024; + +const sendToProvider = async ({ + provider, + recipientId, + text, + buttons, +}: { + provider: ProviderName; + recipientId: string; + text: string; + buttons?: Array<{ title: string; action: string }>; +}) => { + const token = providerTokens[provider]; + if (!token) { + throw new Error(`Missing token for ${provider}`); + } + + return { + provider, + recipientId, + text, + buttons: buttons || [], + accepted: true, + }; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403); + } + + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "integration") || {}; + + try { + const { body, rawBody } = await readJsonBody<{ + provider: ProviderName; + orderId: string; + recipientId: string; + text: string; + buttons?: Array<{ title: string; action: string }>; + workflowAction?: OutboundWorkflowAction; + }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + + await verifyInternalRequest(request, rawBody, { rawBody }); + + const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "chatbot-dispatch", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const dispatchResult = await sendToProvider(body); + + const { error } = await supabase.from("chat_messages").insert({ + order_id: body.orderId, + sender_name: "dispatch-function", + sender_type: "bot", + channel: channelFromProvider(body.provider), + text: body.text, + payload: { + buttons: body.buttons || [], + dispatch_result: dispatchResult, + }, + }); + + if (error) { + throw error; + } + + const orderUpdate = getOrderUpdateForOutboundDispatch(body.workflowAction || "custom_message"); + if (orderUpdate) { + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate.status, + delivery_agreement_status: orderUpdate.deliveryAgreementStatus, + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: `Dispatch ${body.provider}: ${body.workflowAction || "custom_message"}`, + old_status: currentOrder.status, + new_status: orderUpdate.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus, + buttons: body.buttons || [], + }, + }); + + if (historyError) { + throw historyError; + } + } + + return json({ ok: true, dispatchResult }); + } catch (error) { + return json( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + ); + } +}); diff --git a/volumes/functions/transfer-to-logistics/index.ts b/volumes/functions/transfer-to-logistics/index.ts new file mode 100644 index 0000000..abc14d2 --- /dev/null +++ b/volumes/functions/transfer-to-logistics/index.ts @@ -0,0 +1,156 @@ +import { + getOrderUpdateForDeliveryInvitationAction, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 16 * 1024; + +type TransferBody = { + orderId?: string; + reason?: string; + note?: string; + targetStatus?: "Передан логисту" | "Платное хранение"; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403); + } + + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "integration") || {}; + + try { + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { rawBody }); + + if (!body.orderId) { + 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", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const targetStatus = body.targetStatus || "Передан логисту"; + const action = targetStatus === "Платное хранение" ? "mark_paid_storage" : "transfer_to_logistics"; + const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action); + + const { error: invitationError } = await supabase + .from("delivery_invitations") + .update({ + state: targetStatus === "Платное хранение" ? "paid_storage" : "transferred_to_logistics", + ...(targetStatus === "Платное хранение" + ? { paid_storage_at: new Date().toISOString() } + : { logistics_transferred_at: new Date().toISOString() }), + }) + .eq("order_id", body.orderId); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + reason: body.reason || null, + note: body.note || null, + target_status: targetStatus, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: body.orderId, + event_type: + targetStatus === "Платное хранение" ? "delivery_paid_storage_requested" : "delivery_transfer_to_logistics", + direction: "internal", + status: "success", + payload: { + reason: body.reason || null, + note: body.note || null, + target_status: targetStatus, + }, + }); + + return jsonResponse( + { + ok: true, + orderId: body.orderId, + status: orderUpdate?.status, + deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/update-order-group-delivery-choice/index.ts b/volumes/functions/update-order-group-delivery-choice/index.ts new file mode 100644 index 0000000..0817f8e --- /dev/null +++ b/volumes/functions/update-order-group-delivery-choice/index.ts @@ -0,0 +1,230 @@ +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; +const ALLOWED_ROLES = new Set(["manager", "logistician", "admin"]); +const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]); +const DELIVERY_TIME_ALIASES = new Map([ + ["До обеда", "Первая половина дня"], + ["После обеда", "Вторая половина дня"], +]); +const DELIVERY_TIMEZONE = "Europe/Simferopol"; + +type UpdateDeliveryChoiceBody = { + orderGroupId?: string; + deliveryDate?: string; + deliveryTime?: string; +}; + +const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +const getTodayKey = () => { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: DELIVERY_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + + const year = parts.find((part) => part.type === "year")?.value || ""; + const month = parts.find((part) => part.type === "month")?.value || ""; + const day = parts.find((part) => part.type === "day")?.value || ""; + + return `${year}-${month}-${day}`; +}; + +const isWeekendDeliveryDate = (value: string) => { + if (!isValidDate(value)) { + return false; + } + + const date = new Date(`${value}T12:00:00Z`); + const weekday = date.getUTCDay(); + return weekday === 0 || weekday === 6; +}; + +const isAllowedDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey() && !isWeekendDeliveryDate(value); + +const normalizeDeliveryTime = (value: string) => DELIVERY_TIME_ALIASES.get(value) || value; + +const getBearerToken = (request: Request) => { + const authorization = request.headers.get("authorization") || ""; + return authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() + : ""; +}; + +const getUserRole = async ( + supabase: ReturnType, + accessToken: string, +) => { + const { data: authData, error: authError } = await supabase.auth.getUser(accessToken); + if (authError || !authData.user?.id) { + return null; + } + + const { data: profile, error: profileError } = await supabase + .from("users") + .select("id, role_info:roles(name)") + .eq("id", authData.user.id) + .single(); + + if (profileError) { + throw profileError; + } + + const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info; + return { + userId: authData.user.id, + role: roleInfo?.name || "", + }; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const orderGroupId = String(body.orderGroupId || "").trim(); + const deliveryDate = String(body.deliveryDate || "").trim(); + const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim()); + + if (!orderGroupId) { + return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders); + } + + if (!isAllowedDeliveryDate(deliveryDate)) { + return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders); + } + + if (!ALLOWED_DELIVERY_TIMES.has(deliveryTime)) { + return jsonResponse({ ok: false, error: "Выберите первую или вторую половину дня доставки" }, 400, corsHeaders); + } + + const accessToken = getBearerToken(request); + if (!accessToken) { + return jsonResponse({ ok: false, error: "Authentication is required" }, 401, corsHeaders); + } + + const supabase = createServiceClient(); + const actor = await getUserRole(supabase, accessToken); + + if (!actor || !ALLOWED_ROLES.has(actor.role)) { + return jsonResponse({ ok: false, error: "Forbidden" }, 403, corsHeaders); + } + + const ipHash = await hashText(getClientIp(request)); + await requireRateLimit(supabase, { + scope: "order-group-manual-delivery-choice", + key: `${actor.userId}:${ipHash}:${orderGroupId}`, + maxCount: 20, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: currentGroup, error: currentGroupError } = await supabase + .from("order_groups") + .select("id, delivery_status, delivery_invitation_id") + .eq("id", orderGroupId) + .single(); + + if (currentGroupError) { + throw currentGroupError; + } + + const { data: group, error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_status: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId) + .select("*") + .single(); + + if (groupUpdateError) { + throw groupUpdateError; + } + + if (currentGroup.delivery_invitation_id) { + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + confirmed_at: new Date().toISOString(), + }) + .eq("id", currentGroup.delivery_invitation_id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + } + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "order_group_manual_delivery_choice", + direction: "internal", + status: "success", + payload: { + order_group_id: orderGroupId, + actor_user_id: actor.userId, + actor_role: actor.role, + old_delivery_status: currentGroup.delivery_status || null, + new_delivery_status: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + }, + }); + + return jsonResponse( + { + ok: true, + orderGroup: group, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/volumes/functions/verify-otp/index.ts b/volumes/functions/verify-otp/index.ts new file mode 100644 index 0000000..1fed0f0 --- /dev/null +++ b/volumes/functions/verify-otp/index.ts @@ -0,0 +1,190 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; +const OTP_EXPIRY_SECONDS = 600; // 10 minutes + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + const email = String(body.email || "").trim().toLowerCase(); + const otp = String(body.otp || "").trim(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); + } + + if (!otp || otp.length < 4 || otp.length > 12) { + return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); + } + + const supabase = createServiceClient(); + const emailHash = await hashText(email); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "otp-verify", + key: `${ipHash}:${emailHash}`, + maxCount: 5, + windowSeconds: 600, + blockSeconds: 1800, + }); + + // 1. Find the most recent unverified OTP for this email + const { data: otpRecords, error: fetchError } = await supabase + .from("login_otps") + .select("*") + .eq("email", email) + .eq("verified", false) + .order("created_at", { ascending: false }) + .limit(1); + + if (fetchError || !otpRecords || otpRecords.length === 0) { + return jsonResponse({ ok: false, error: "Неверный или просроченный код" }, 400, corsHeaders); + } + + const otpRecord = otpRecords[0]; + + // 2. Check expiry (10 minutes) + const createdAt = new Date(otpRecord.created_at); + const now = new Date(); + const elapsedSeconds = (now.getTime() - createdAt.getTime()) / 1000; + + if (elapsedSeconds > OTP_EXPIRY_SECONDS) { + await supabase.from("login_otps").delete().eq("id", otpRecord.id); + return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders); + } + + // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records) + const submittedOtpHash = await hashText(otp); + let otpMatches = false; + + if (otpRecord.otp_code_hash) { + // New flow: compare SHA-256 hashes + otpMatches = otpRecord.otp_code_hash === submittedOtpHash; + } else if (otpRecord.otp_code) { + // Legacy fallback: plaintext comparison for old records + otpMatches = otpRecord.otp_code === otp; + } + + if (!otpMatches) { + return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders); + } + + // 4. Mark as verified and clear plaintext if present + await supabase + .from("login_otps") + .update({ verified: true, otp_code: "" }) + .eq("id", otpRecord.id); + + // Delete all other unverified OTPs for this email + await supabase + .from("login_otps") + .delete() + .eq("email", email) + .eq("verified", false); + + // 5. Find user by email to get user_id + const { data: users } = await supabase + .from("users") + .select("id, name, roles(name)") + .eq("email", email) + .limit(1); + + if (!users || users.length === 0) { + return jsonResponse({ ok: false, error: "Пользователь не найден" }, 400, corsHeaders); + } + + const userId = users[0].id; + const userName = users[0].name || null; + const userRole = users[0].roles?.name || null; + + // Update the login_otps record with user info + await supabase + .from("login_otps") + .update({ name: userName, role: userRole }) + .eq("id", otpRecord.id); + + // 6. Create session using Supabase admin API + const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email, + }); + + if (linkError || !linkData) { + console.error("generateLink error:", linkError); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); + } + + const generatedLink = linkData as any; + const tokenHash = generatedLink.properties?.hashed_token || generatedLink.properties?.token_hash; + + if (!tokenHash) { + console.error("No token in generateLink response"); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); + } + + const { data: verifyData, error: verifyError } = await supabase.auth.verifyOtp({ + type: "magiclink", + token_hash: tokenHash, + }); + + if (verifyError) { + console.error("verifyOtp error:", verifyError); + return jsonResponse({ ok: false, error: "Ошибка авторизации" }, 500, corsHeaders); + } + + const session = verifyData.session; + const user = verifyData.user; + + return jsonResponse( + { + ok: true, + session: session || null, + user: user || null, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file