- Группа
- Клиент
- Номера
+ Группа / Клиент
+ Счёта
Статус
Водитель
Дата доставки
@@ -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 ? (
- {isGuideOpen ? "Назад" : "?"}
+ ?
) : 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 */}
+
+ {
+ setActiveTab(TAB_DELIVERY);
+ setSelectedSlotId(null);
+ setSelectedSlot(null);
+ setActionMessage("");
+ }}
+ >
+ 🚚 Доставка
+
+ {
+ setActiveTab(TAB_PICKUP);
+ setSelectedSlotId(null);
+ setSelectedSlot(null);
+ setActionMessage("");
+ }}
+ >
+ 🏪 Самовывоз
+
+
+
+ {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