feat: add pickup (самовывоз) delivery type

- New status pickup in delivery workflow
- DB: delivery_type, pickup_date, pickup_time_slot columns
- Client page: tabs Доставка/Самовывоз with PickupSlotsPicker
- PickupSlotsPicker: today/tomorrow/day-after with half-day slots
- Storage notice: free 2 workdays, then 300₽/day
- OrderDetailPanel: delivery type tabs, pickup date/time, status button
- Edge function: delivery_type/pickup fields in confirm-delivery-choice
- RPC: confirm_delivery_choice_by_token updated for pickup
- orderGroupRepository: full pickup field mapping
This commit is contained in:
root 2026-06-10 12:02:46 +00:00
parent 3c22eb71ab
commit e05613ac1d
55 changed files with 4659 additions and 613 deletions

View File

@ -455,6 +455,7 @@ services:
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
APP_ALLOWED_ORIGINS: https://dost.supersamsev.ru,https://supa.supersamsev.ru,https://supasevdev.mkn8n.ru
# TODO: Allow configuring VERIFY_JWT per function. # TODO: Allow configuring VERIFY_JWT per function.
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
command: command:

View File

@ -6,14 +6,15 @@
<meta name="theme-color" content="#12805c" /> <meta name="theme-color" content="#12805c" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="SuperSam" />
<meta <meta
name="description" name="description"
content="Демо-панель управления доставкой и заказами с офлайн-доступом после первого запуска." content="Панель управления доставкой и заказами с офлайн-доступом после первого запуска."
/> />
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" /> <link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Construction Delivery Control</title> <title>SuperSam Доставка</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -10,16 +10,28 @@
"lang": "ru", "lang": "ru",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.svg", "src": "/icons/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/svg+xml", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/icons/icon-512.svg", "src": "/icons/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/svg+xml", "type": "image/png",
"purpose": "any" "purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

View File

@ -1,9 +1,9 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) { if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v4"; const STATIC_CACHE = "construction-delivery-static-v5";
const RUNTIME_CACHE = "construction-delivery-runtime-v4"; const RUNTIME_CACHE = "construction-delivery-runtime-v5";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
@ -93,8 +93,8 @@ self.addEventListener("push", (event) => {
const title = data.title || "Уведомление"; const title = data.title || "Уведомление";
const options = { const options = {
body: data.body || "", body: data.body || "",
icon: data.icon || "/icons/icon-192.svg", icon: data.icon || "/icons/icon-192.png",
badge: data.badge || "/icons/icon-192.svg", badge: data.badge || "/icons/icon-192.png",
data: data.data || {}, data: data.data || {},
tag: data.tag || "default", tag: data.tag || "default",
vibrate: [100, 50, 100], vibrate: [100, 50, 100],

View File

@ -51,7 +51,7 @@ export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable })
</button> </button>
{showTip && ( {showTip && (
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg"> <div className="absolute left-1/2 -translate-x-1/2 top-full z-50 mt-2 w-60 sm:left-auto sm:translate-x-0 sm:right-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg">
{isIOS ? ( {isIOS ? (
<> <>
<p className="font-medium">Установка на iOS</p> <p className="font-medium">Установка на iOS</p>

View File

@ -1,13 +1,18 @@
import React from "react"; import React from "react";
import { Button } from "./Button";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
export const ThemeToggle = () => { export const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
return ( return (
<Button variant="secondary" size="sm" onClick={toggleTheme}> <button
{theme === "light" ? "Тёмная тема" : "Светлая тема"} type="button"
</Button> onClick={toggleTheme}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-base transition hover:bg-[var(--color-accent-soft)]"
aria-label={theme === "light" ? "Тёмная тема" : "Светлая тема"}
title={theme === "light" ? "Тёмная тема" : "Светлая тема"}
>
{theme === "light" ? "🌙" : "☀️"}
</button>
); );
}; };

View File

@ -5,10 +5,12 @@ import { supabase } from "../../supabaseClient";
export const StopWordsPanel = () => { export const StopWordsPanel = () => {
const [words, setWords] = React.useState([]); const [words, setWords] = React.useState([]);
const [scope, setScope] = React.useState("everywhere");
const [newWord, setNewWord] = React.useState(""); const [newWord, setNewWord] = React.useState("");
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isSavingScope, setIsSavingScope] = React.useState(false);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [deletingId, setDeletingId] = React.useState(null); const [savingId, setSavingId] = React.useState(null);
const loadWords = React.useCallback(async () => { const loadWords = React.useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@ -25,7 +27,18 @@ export const StopWordsPanel = () => {
setIsLoading(false); setIsLoading(false);
}, []); }, []);
React.useEffect(() => { loadWords(); }, [loadWords]); const loadScope = React.useCallback(async () => {
const { data } = await supabase
.from("stop_words_scope")
.select("scope")
.eq("id", 1)
.single();
if (data) setScope(data.scope);
}, []);
React.useEffect(() => {
Promise.all([loadWords(), loadScope()]);
}, [loadWords, loadScope]);
const handleAdd = async () => { const handleAdd = async () => {
const trimmed = newWord.trim().toLowerCase(); const trimmed = newWord.trim().toLowerCase();
@ -47,7 +60,7 @@ export const StopWordsPanel = () => {
}; };
const handleDelete = async (id) => { const handleDelete = async (id) => {
setDeletingId(id); setSavingId(id);
const { error: deleteError } = await supabase const { error: deleteError } = await supabase
.from("stop_words") .from("stop_words")
.delete() .delete()
@ -57,7 +70,21 @@ export const StopWordsPanel = () => {
} else { } else {
await loadWords(); await loadWords();
} }
setDeletingId(null); setSavingId(null);
};
const handleScopeChange = async (newScope) => {
setIsSavingScope(true);
setError("");
const { error: upsertError } = await supabase
.from("stop_words_scope")
.upsert({ id: 1, scope: newScope }, { onConflict: "id" });
if (upsertError) {
setError(upsertError.message);
} else {
setScope(newScope);
}
setIsSavingScope(false);
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@ -72,8 +99,46 @@ export const StopWordsPanel = () => {
<div> <div>
<h2 className="text-lg font-semibold">Стоп-слова</h2> <h2 className="text-lg font-semibold">Стоп-слова</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
Позиции с этими словами не показываются клиентам в карточке доставки. Позиции с этими словами скрываются из состава заказа.
Добавляйте слова-маркеры: «сверление», «обмер» и т.д. </p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--color-text)]">Где применять стоп-слова:</p>
<div className="flex gap-2">
<button
type="button"
disabled={isSavingScope}
onClick={() => handleScopeChange("everywhere")}
className={[
"rounded-full border px-4 py-2 text-sm font-medium transition",
scope === "everywhere"
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
isSavingScope ? "opacity-50" : "",
].join(" ")}
>
Везде
</button>
<button
type="button"
disabled={isSavingScope}
onClick={() => handleScopeChange("client_only")}
className={[
"rounded-full border px-4 py-2 text-sm font-medium transition",
scope === "client_only"
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
isSavingScope ? "opacity-50" : "",
].join(" ")}
>
Только карточка клиента
</button>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
{scope === "everywhere"
? "Стоп-слова скрывают позиции и в панели управления, и в карточке клиента."
: "Стоп-слова скрывают позиции только на странице выбора времени доставки."}
</p> </p>
</div> </div>
@ -110,12 +175,12 @@ export const StopWordsPanel = () => {
{w.word} {w.word}
<button <button
type="button" type="button"
disabled={deletingId === w.id} disabled={savingId === w.id}
onClick={() => handleDelete(w.id)} onClick={() => handleDelete(w.id)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40" className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40"
aria-label={`Удалить ${w.word}`} aria-label={`Удалить ${w.word}`}
> >
×
</button> </button>
</span> </span>
))} ))}

View File

@ -22,6 +22,7 @@ export const DeliveryChoiceFlow = ({
invitation = {}, invitation = {},
selectedSlot = null, selectedSlot = null,
onConfirmChoice = () => {}, onConfirmChoice = () => {},
deliveryType = "delivery",
}) => { }) => {
const state = invitation.state || "awaiting_choice"; const state = invitation.state || "awaiting_choice";
const isActive = ACTIVE_STATES.has(state); const isActive = ACTIVE_STATES.has(state);
@ -36,16 +37,22 @@ export const DeliveryChoiceFlow = ({
); );
} }
const typeLabel = deliveryType === "pickup" ? "самовывоз" : "доставку";
return ( return (
<Panel className="space-y-5 p-5 sm:p-6"> <Panel className="space-y-5 p-5 sm:p-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Согласование доставки</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
{deliveryType === "pickup" ? "Согласование самовывоза" : "Согласование доставки"}
</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1> <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
{deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"}
</h1>
<Badge tone="warning">{STATE_LABELS[state]}</Badge> <Badge tone="warning">{STATE_LABELS[state]}</Badge>
</div> </div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
{invitationReference}. Выберите удобную половину дня. {invitationReference}. Выберите удобную половину дня для {typeLabel}.
</p> </p>
</div> </div>

View File

@ -2,6 +2,7 @@ import React from "react";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { getInvitationReferenceLabel } from "./invitationReference"; import { getInvitationReferenceLabel } from "./invitationReference";
import { supabase } from "../../supabaseClient";
const flattenOrderProducts = (rawItems) => { const flattenOrderProducts = (rawItems) => {
if (!Array.isArray(rawItems) || rawItems.length === 0) return []; if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
@ -30,6 +31,7 @@ const flattenOrderProducts = (rawItems) => {
name: pName, name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(), unit: String(p.product_ed || p.unit || "").trim(),
_sourceOrder: sub,
}); });
} }
} }
@ -42,6 +44,7 @@ const flattenOrderProducts = (rawItems) => {
name: pName, name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(), unit: String(p.product_ed || p.unit || "").trim(),
_sourceOrder: item,
}); });
} }
} }
@ -54,6 +57,7 @@ const flattenOrderProducts = (rawItems) => {
name, name,
quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(), quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(),
unit: String(item.product_ed || item.unit || "").trim(), unit: String(item.product_ed || item.unit || "").trim(),
_sourceOrder: item,
}); });
} }
@ -67,10 +71,26 @@ const matchesStopWord = (name, stopWords) => {
}; };
export const OrderCompositionPanel = ({ invitation = {} }) => { export const OrderCompositionPanel = ({ invitation = {} }) => {
const stopWords = invitation.stopWords || []; const [stopWords, setStopWords] = React.useState([]);
const [stopWordsLoaded, setStopWordsLoaded] = React.useState(false);
const [scopeActive, setScopeActive] = React.useState(true);
React.useEffect(() => {
if (!supabase) { setStopWordsLoaded(true); return; }
Promise.all([
supabase.from("stop_words").select("word"),
supabase.from("stop_words_scope").select("scope").eq("id", 1).single(),
]).then(([{ data: wordsData }, { data: scopeData }]) => {
if (wordsData) setStopWords(wordsData.map((d) => d.word));
setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only");
setStopWordsLoaded(true);
})
.catch(() => setStopWordsLoaded(true));
}, []);
const rawItems = invitation.orderItems || invitation.items || []; const rawItems = invitation.orderItems || invitation.items || [];
const allProducts = flattenOrderProducts(rawItems); const allProducts = flattenOrderProducts(rawItems);
const products = stopWords.length const products = (stopWords.length && scopeActive)
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords)) ? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
: allProducts; : allProducts;
@ -79,6 +99,8 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
// Hide the entire panel if there are no products to show and some were filtered
if (products.length === 0 && filteredCount > 0) return null;
if (products.length === 0 && filteredCount === 0) return null; if (products.length === 0 && filteredCount === 0) return null;
return ( return (
@ -118,11 +140,6 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
) : null} ) : null}
</div> </div>
))} ))}
{products.length === 0 && filteredCount > 0 && (
<p className="text-sm text-[var(--color-text-muted)]">
Все позиции исключены из отображения.
</p>
)}
</div> </div>
)} )}
</Panel> </Panel>

View File

@ -0,0 +1,189 @@
import React from "react";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting";
const DELIVERY_TIMEZONE = "Europe/Simferopol";
const getCrimeaTodayKey = (referenceDate = new Date()) => {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: DELIVERY_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(referenceDate);
const year = parts.find((p) => p.type === "year")?.value || "";
const month = parts.find((p) => p.type === "month")?.value || "";
const day = parts.find((p) => p.type === "day")?.value || "";
return `${year}-${month}-${day}`;
};
const addDaysKey = (dateKey, amount) => {
const base = new Date(`${dateKey}T12:00:00Z`);
if (Number.isNaN(base.getTime())) return "";
base.setUTCDate(base.getUTCDate() + amount);
return base.toISOString().slice(0, 10);
};
const getCrimeaHour = (referenceDate = new Date()) => {
return parseInt(
new Intl.DateTimeFormat("ru-RU", {
timeZone: DELIVERY_TIMEZONE,
hour: "numeric",
hour12: false,
}).format(referenceDate),
10
);
};
const isWeekend = (dateKey) => {
const d = new Date(`${dateKey}T12:00:00Z`);
const day = d.getUTCDay();
return day === 0 || day === 6;
};
const getNextWorkday = (dateKey) => {
let next = addDaysKey(dateKey, 1);
while (isWeekend(next)) {
next = addDaysKey(next, 1);
}
return next;
};
const getPickupSlots = (referenceDate = new Date()) => {
const todayKey = getCrimeaTodayKey(referenceDate);
const hour = getCrimeaHour(referenceDate);
const isTodayWorkday = !isWeekend(todayKey);
const slots = [];
if (isTodayWorkday && hour < 12) {
slots.push({
id: `pickup-${todayKey}-first`,
date: todayKey,
time: "Первая половина дня",
label: "Сегодня",
pickupType: "today",
});
}
const tomorrow = addDaysKey(todayKey, 1);
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
slots.push({
id: `pickup-${tomorrowWorkday}-first`,
date: tomorrowWorkday,
time: "Первая половина дня",
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
pickupType: "tomorrow",
});
slots.push({
id: `pickup-${tomorrowWorkday}-second`,
date: tomorrowWorkday,
time: "Вторая половина дня",
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
pickupType: "tomorrow",
});
const dayAfter = addDaysKey(tomorrowWorkday, 1);
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
slots.push({
id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday,
time: "Первая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
});
slots.push({
id: `pickup-${dayAfterWorkday}-second`,
date: dayAfterWorkday,
time: "Вторая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
});
return slots;
};
const FREE_STORAGE_NOTICE = (
<div className="mt-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6 text-[var(--color-text-muted)]">
<p className="font-semibold text-[var(--color-text)]"> Условия хранения</p>
<p className="mt-1">
Бесплатное хранение <strong>2 рабочих дня</strong> с даты готовности.
</p>
<p>
Начиная с 3-го рабочего дня <strong>300 /день</strong> платного хранения.
</p>
</div>
);
export const PickupSlotsPicker = ({
onSelectSlot,
selectedSlotId,
referenceDate = new Date(),
}) => {
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]);
if (!slots.length) {
return (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">
Нет доступных слотов для самовывоза.
</p>
</Panel>
);
}
const grouped = React.useMemo(() => {
const map = new Map();
for (const slot of slots) {
if (!map.has(slot.date)) map.set(slot.date, []);
map.get(slot.date).push(slot);
}
return Array.from(map.entries());
}, [slots]);
return (
<div className="space-y-4">
{grouped.map(([date, dateSlots]) => (
<details
key={date}
className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur"
open
>
<summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium">
Самовывоз{" "}
{dateSlots[0]?.label
? `${dateSlots[0].label.charAt(0).toLowerCase()}${dateSlots[0].label.slice(1)}`
: ""}{" "}
· {formatDeliveryDate(date)}
</h4>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div>
</summary>
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
<div className="grid gap-3 sm:grid-cols-2">
{dateSlots.map((slot) => {
const isSelected = selectedSlotId === slot.id;
return (
<Button
key={slot.id}
variant={isSelected ? "primary" : "secondary"}
aria-pressed={isSelected}
onClick={() => onSelectSlot(slot)}
>
{slot.time}
{isSelected ? " — Выбрано" : ""}
</Button>
);
})}
</div>
</div>
</details>
))}
{FREE_STORAGE_NOTICE}
</div>
);
};

View File

@ -1,8 +1,15 @@
import React from "react"; import React from "react";
import { supabase } from "../../supabaseClient";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
const parseOrderItems = (order) => { const parseOrderItems = (order) => {
if (!order) return []; if (!order) return [];
@ -88,7 +95,25 @@ const parseOrderItems = (order) => {
}; };
export const DriverShipmentPanel = ({ order, onShipmentChange }) => { export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
const items = React.useMemo(() => parseOrderItems(order), [order]); const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true);
React.useEffect(() => {
if (!supabase) return;
Promise.all([
supabase.from("stop_words").select("word"),
supabase.from("stop_words_scope").select("scope").eq("id", 1).single(),
]).then(([{ data: wordsData }, { data: scopeData }]) => {
if (wordsData) setStopWords(wordsData.map((d) => d.word));
setScopeActive(scopeData?.scope === "everywhere" || scopeData?.scope === "client_only");
}).catch(() => {});
}, []);
const allItems = React.useMemo(() => parseOrderItems(order), [order]);
const items = React.useMemo(() => {
if (!stopWords.length || !scopeActive) return allItems;
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
}, [allItems, stopWords, scopeActive]);
const [shippedItems, setShippedItems] = React.useState(new Set()); const [shippedItems, setShippedItems] = React.useState(new Set());
const [comments, setComments] = React.useState({}); const [comments, setComments] = React.useState({});
const [commentInput, setCommentInput] = React.useState(""); const [commentInput, setCommentInput] = React.useState("");

View File

@ -1,3 +1,43 @@
const DriverShipmentReport = ({ shipmentData }) => {
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
return (
<Panel className="space-y-4 p-5 border-[var(--color-warning)]">
<div className="flex items-center gap-2">
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное доставлено.
</p>
<div className="space-y-2">
{shipmentData.map((item) => (
<div
key={item.id || item.name}
className="rounded-[18px] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-4 py-3 text-sm"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--color-text)]">{item.name}</span>
{item.quantity || item.unit ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{item.comment ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p>
) : (
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
)}
</div>
))}
</div>
</Panel>
);
};
import React from "react"; import React from "react";
import { formatDateTime } from "../../utils/formatters"; import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
@ -5,6 +45,7 @@ import { Button } from "../UI/Button";
import { Select } from "../UI/Select"; import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel"; import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import { supabase } from "../../supabaseClient";
import { import {
getOrderGroupDeliveryStatusLabel, getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel, getOrderGroupDisplayStatusLabel,
@ -39,6 +80,18 @@ const renderList = (values) => {
const renderValue = (value) => value || "Нет данных"; const renderValue = (value) => value || "Нет данных";
const normalizeNom = (nom) => {
if (!nom) return '';
// 1C escapes backslashes: "СФ Т\\ЕА-33584" normalize for comparison
return String(nom).replace(/\\\\/g, '\\').trim();
};
const getAllBillNumbers = (order) => {
const orders = parseOrderList(order);
if (!orders.length) return order.orderNumbers || [];
return orders.map((o) => o.nom || o.name || '').filter(Boolean);
};
const parseOrderList = (order) => { const parseOrderList = (order) => {
if (!order) return []; if (!order) return [];
@ -61,18 +114,24 @@ const parseOrderList = (order) => {
} }
// Fallback: sourceOrders (1C exchange data) // Fallback: sourceOrders (1C exchange data)
// Collect orderList from ALL source orders, not just the first one // 1C sends the FULL order composition (main + associated bills) in EVERY source order's orderList.
// We must deduplicate by nom to avoid showing the same items multiple times.
if (order.sourceOrders) { if (order.sourceOrders) {
let parsed = order.sourceOrders; let parsed = order.sourceOrders;
if (typeof parsed === 'string') { if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ } try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
} }
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
const seen = new Set();
const allItems = []; const allItems = [];
for (const src of parsed) { for (const src of parsed) {
if (src && Array.isArray(src.orderList)) { if (src && Array.isArray(src.orderList)) {
for (const ol of src.orderList) { for (const ol of src.orderList) {
if (ol && (ol.items || ol.nom || ol.name)) { if (ol && (ol.items || ol.nom || ol.name)) {
const normalizedNom = normalizeNom(ol.nom || ol.name || '');
// Deduplicate by nom 1C repeats same orderList in every source order
if (seen.has(normalizedNom)) continue;
seen.add(normalizedNom);
allItems.push(ol); allItems.push(ol);
} }
} }
@ -246,10 +305,41 @@ const normalizeDateForInput = (value) => {
return ""; return "";
}; };
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
const useStopWords = () => {
const [stopWords, setStopWords] = React.useState([]);
const [active, setActive] = React.useState(true);
React.useEffect(() => {
if (!supabase) return;
Promise.all([
supabase.from("stop_words").select("word").then(r => r.data || []),
supabase.from("stop_words_scope").select("scope").eq("id", 1).single().then(r => r.data),
]).then(([words, scopeRow]) => {
setStopWords(words.map((d) => d.word));
setActive(scopeRow?.scope !== "client_only");
});
}, []);
return { stopWords, active };
};
const CollapsibleOrderComposition = ({ order }) => { const CollapsibleOrderComposition = ({ order }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const { stopWords, active } = useStopWords();
const orders = parseOrderList(order); const orders = parseOrderList(order);
const totalPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0); const allPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0);
const filteredPositions = active ? orders.reduce((sum, o) => {
if (!o.items) return sum;
return sum + o.items.filter((item) => {
const name = String(item.product_name || item.name || item.title || "");
return !matchesStopWord(name, stopWords);
}).length;
}, 0) : allPositions;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -260,7 +350,11 @@ const CollapsibleOrderComposition = ({ order }) => {
> >
<span className="font-semibold">Состав заказа</span> <span className="font-semibold">Состав заказа</span>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]"> <span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{totalPositions > 0 ? `${totalPositions} поз.` : ''} {active && filteredPositions < allPositions
? `${filteredPositions} поз. из ${allPositions}`
: filteredPositions > 0
? `${filteredPositions} поз.`
: ''}
<svg <svg
className="h-4 w-4 transition-transform" className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
@ -283,9 +377,20 @@ const CollapsibleOrderComposition = ({ order }) => {
<div className="mb-3 pb-2 border-b border-[var(--color-border)]"> <div className="mb-3 pb-2 border-b border-[var(--color-border)]">
<p className="font-bold text-[var(--color-text)] text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p> <p className="font-bold text-[var(--color-text)] text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p>
</div> </div>
{orderItem.items && orderItem.items.length > 0 ? ( {(() => {
const filtered = (orderItem.items || []).filter((item) => {
const name = String(item.product_name || item.name || item.title || "");
return active ? !matchesStopWord(name, stopWords) : true;
});
if (filtered.length === 0 && active && (orderItem.items || []).length > 0) {
return <p className="text-sm text-[var(--color-text-muted)] italic">Только услуги скрыты стоп-словами</p>;
}
if (filtered.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>;
}
return (
<div className="space-y-2"> <div className="space-y-2">
{orderItem.items.map((item, itemIdx) => ( {filtered.map((item, itemIdx) => (
<div key={itemIdx} className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm"> <div key={itemIdx} className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm">
<span className="text-[var(--color-text)] min-w-0">{item.product_name || item.name || item.title || ''}</span> <span className="text-[var(--color-text)] min-w-0">{item.product_name || item.name || item.title || ''}</span>
<span className="text-[var(--color-text-muted)] whitespace-nowrap text-right"> <span className="text-[var(--color-text-muted)] whitespace-nowrap text-right">
@ -294,9 +399,8 @@ const CollapsibleOrderComposition = ({ order }) => {
</div> </div>
))} ))}
</div> </div>
) : ( );
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p> })()}
)}
</div> </div>
)) ))
)} )}
@ -442,6 +546,7 @@ export const OrderDetailPanel = ({
userRole, userRole,
}) => { }) => {
const [problemReason, setProblemReason] = React.useState(null); const [problemReason, setProblemReason] = React.useState(null);
const [pendingStatus, setPendingStatus] = React.useState(null);
const [deliveryDate, setDeliveryDate] = React.useState(""); const [deliveryDate, setDeliveryDate] = React.useState("");
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [formMessage, setFormMessage] = React.useState(""); const [formMessage, setFormMessage] = React.useState("");
@ -449,6 +554,9 @@ export const OrderDetailPanel = ({
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const [driverMessage, setDriverMessage] = React.useState(""); const [driverMessage, setDriverMessage] = React.useState("");
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery");
const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || "");
const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
const [currentMonth, setCurrentMonth] = React.useState(() => { const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate); const existingDeliveryDate = fromDateKey(order?.deliveryDate);
@ -483,8 +591,11 @@ export const OrderDetailPanel = ({
const selectedDate = fromDateKey(selectedDateKey) || new Date(); const selectedDate = fromDateKey(selectedDateKey) || new Date();
setCurrentMonth(startOfMonth(selectedDate)); setCurrentMonth(startOfMonth(selectedDate));
setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay)); setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay));
setDeliveryType(order?.deliveryType || "delivery");
setPickupDate(order?.pickupDate || "");
setPickupTimeSlot(normalizeDeliveryTimeChoice(order?.pickupTimeSlot || order?.deliveryTime || order?.deliveryHalfDay));
setFormMessage(""); setFormMessage("");
}, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime]); }, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime, order?.deliveryType, order?.pickupDate, order?.pickupTimeSlot]);
if (!order) { if (!order) {
return ( return (
@ -507,21 +618,25 @@ export const OrderDetailPanel = ({
}, []); }, []);
const handleSaveDeliveryChoice = async () => { const handleSaveDeliveryChoice = async () => {
if (!deliveryDate || !deliveryTime) { const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
setFormMessage("Укажите дату и половину дня доставки."); const effectiveTime = deliveryType === "pickup" ? pickupTimeSlot : deliveryTime;
if (!effectiveDate || !effectiveTime) {
setFormMessage(deliveryType === "pickup" ? "Укажите дату и время самовывоза." : "Укажите дату и половину дня доставки.");
return; return;
} }
if (!isFutureDeliveryDate(deliveryDate)) { if (!isFutureDeliveryDate(effectiveDate)) {
setFormMessage("Выберите дату доставки позже сегодняшнего дня."); setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
return; return;
} }
try { try {
const result = await onSaveManualDeliveryChoice?.({ const result = await onSaveManualDeliveryChoice?.({
orderGroupId: order.id, orderGroupId: order.id,
deliveryDate, deliveryDate: deliveryType === "pickup" ? pickupDate : deliveryDate,
deliveryTime, deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime,
deliveryType,
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
}); });
if (result?.success) { if (result?.success) {
@ -577,7 +692,7 @@ export const OrderDetailPanel = ({
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge> <Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
</div> </div>
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-3"> <div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата доставки Дата доставки
@ -590,6 +705,12 @@ export const OrderDetailPanel = ({
</p> </p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p> <p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
</div> </div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Тип доставки
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{order.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</p>
</div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Водитель Водитель
@ -661,6 +782,38 @@ export const OrderDetailPanel = ({
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."} : "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
</p> </p>
</div> </div>
{/* Delivery type tabs */}
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
type="button"
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
deliveryType === "delivery"
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
>
🚚 Доставка
</button>
<button
type="button"
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
deliveryType === "pickup"
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
>
🏪 Самовывоз
</button>
</div>
{deliveryType === "pickup" && (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
<p className="font-semibold text-[var(--color-text)]"> Условия хранения</p>
<p className="mt-1">Бесплатное хранение <strong>2 рабочих дня</strong> с даты готовности.</p>
<p>Начиная с 3-го рабочего дня <strong>300 /день</strong> платного хранения.</p>
</div>
)}
{isDeliveryAgreed && !isEditingDate ? ( {isDeliveryAgreed && !isEditingDate ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]"> <div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
@ -688,6 +841,7 @@ export const OrderDetailPanel = ({
) : null} ) : null}
</div> </div>
) : ( ) : (
{deliveryType === "delivery" ? (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
<div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4"> <div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4">
<button <button
@ -817,6 +971,62 @@ export const OrderDetailPanel = ({
</button> </button>
))} ))}
</div> </div>
) : (
<div className="space-y-3">
<button
type="button"
aria-label="Дата самовывоза"
aria-expanded={isCalendarOpen}
className="flex min-h-[54px] w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-left text-sm font-medium !text-[var(--color-text)] transition hover:border-[var(--color-accent)] focus:border-[var(--color-accent)] focus:outline-none"
onClick={() => setIsCalendarOpen((current) => !current)}
>
<span>{pickupDate ? formatDateForDisplay(pickupDate) : "Выберите дату"}</span>
<span aria-hidden="true" className="text-[var(--color-text-muted)]"></span>
</button>
{isCalendarOpen ? (
<div className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-soft md:relative md:z-50">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Календарь самовывоза</p>
<h4 className="mt-1 text-base font-semibold capitalize" style={{ color: "var(--color-text)" }}>{monthLabel}</h4>
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={!canGoBack} aria-label="Предыдущий месяц" className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-40" onClick={() => setCurrentMonth((month) => addMonths(month, -1))}></button>
<button type="button" aria-label="Следующий месяц" className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]" onClick={() => setCurrentMonth((month) => addMonths(month, 1))}></button>
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase text-[var(--color-text-muted)]">
{WEEK_DAY_LABELS.map((day) => (<div key={day} className="px-1 py-1">{day}</div>))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => {
if (!day) return <div key={`empty-${index}`} className="aspect-square" />;
const dateKey = toDateKey(day);
const isWeekend = isWeekendDate(day);
const isSelectable = isSelectableCalendarDate(day, minSelectableDateKey);
const isSelected = dateKey === pickupDate;
const isDisabled = !isSelectable;
const dayNumber = String(day.getDate()).padStart(2, "0");
return (
<button key={dateKey} type="button" disabled={isDisabled} title={isWeekend ? "Выходной" : isSelectable ? "Можно выбрать" : "Недоступно"} className={["relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition", isSelected ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]" : isWeekend ? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]" : "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]", isDisabled ? "cursor-not-allowed opacity-45" : ""].join(" ")} onClick={() => { if (!isDisabled) { setPickupDate(dateKey); setFormMessage(""); setIsCalendarOpen(false); } }}>
<span>{dayNumber}</span>
{isWeekend ? (<span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70" />) : null}
</button>
);
})}
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Выходные отмечены пунктиром и недоступны.</p>
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{DELIVERY_TIME_OPTIONS.map((option) => (
<button key={option} type="button" aria-pressed={pickupTimeSlot === option} className={["min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition", pickupTimeSlot === option ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]" : "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]"].join(" ")} onClick={() => { setPickupTimeSlot(option); setFormMessage(""); }}>
{option}
</button>
))}
</div>
</div>
)}
<Button <Button
className="w-full md:w-[180px] md:flex-none md:self-start" className="w-full md:w-[180px] md:flex-none md:self-start"
onClick={handleSaveDeliveryChoice} onClick={handleSaveDeliveryChoice}
@ -824,7 +1034,6 @@ export const OrderDetailPanel = ({
> >
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"} {isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
</Button> </Button>
</div>
)} )}
{formMessage ? ( {formMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p> <p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
@ -916,6 +1125,7 @@ export const OrderDetailPanel = ({
{ value: "loaded", label: "Загружено", manual: true }, { value: "loaded", label: "Загружено", manual: true },
{ value: "on_route", label: "В пути", manual: true }, { value: "on_route", label: "В пути", manual: true },
{ value: "delivered", label: "Доставлено", manual: true }, { value: "delivered", label: "Доставлено", manual: true },
{ value: "pickup", label: "Самовывоз", manual: true },
{ value: "problem", label: "Проблема", manual: true }, { value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true }, { value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => { ].map((statusOption) => {
@ -975,24 +1185,14 @@ export const OrderDetailPanel = ({
<div> <div>
<strong>Статус доставки</strong> <strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
Обновите статус по мере выполнения доставки. Выберите статус и нажмите «Сохранить».
</p> </p>
</div> </div>
{problemReason !== null ? ( {problemReason !== null ? (
<ProblemReasonModal <ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => { onSelect={(reasonValue, reasonLabel) => {
onChangeDeliveryStatus({ setPendingStatus({ value: "problem", reason: reasonValue, reasonLabel });
orderGroupId: order.id,
status: "problem",
details: { reason: reasonValue, reasonLabel },
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("Статус обновлён: проблема — " + reasonLabel);
}
setProblemReason(null); setProblemReason(null);
});
}} }}
onCancel={() => setProblemReason(null)} onCancel={() => setProblemReason(null)}
/> />
@ -1003,54 +1203,76 @@ export const OrderDetailPanel = ({
const IN_TRANSIT_STATUSES = ["loaded", "on_route"]; const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus); const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let availableButtons = []; let statusOptions = [];
if (currentStatus === "driver_assigned") { if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
availableButtons = [ statusOptions = [];
{ value: "loaded", label: "Загружено" },
{ value: "problem", label: "Проблема" },
];
} else if (isOnRoute) {
availableButtons = [
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
];
} else if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
availableButtons = [];
} else { } else {
availableButtons = [ statusOptions = [
{ value: "loaded", label: "Загружено" },
{ value: "delivered", label: "Доставлено" }, { value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" }, { value: "problem", label: "Проблема" },
]; ];
} }
return availableButtons.map((statusOption) => ( if (statusOptions.length === 0) return null;
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
const isDeliveredBtn = statusOption.value === "delivered";
const deliveryBlocked = isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered;
return (
<Button <Button
key={statusOption.value} key={statusOption.value}
variant={currentStatus === statusOption.value ? "primary" : "secondary"} variant={isSelected ? "primary" : "secondary"}
disabled={deliveryBlocked}
title={deliveryBlocked ? "Сначала отметьте все позиции как отгруженные" : undefined}
onClick={() => { onClick={() => {
if (statusOption.value === "problem") { if (statusOption.value === "problem") {
setProblemReason("selecting"); setProblemReason("selecting");
return; return;
} }
setPendingStatus({ value: statusOption.value });
}}
>
{statusOption.label}
{isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered ? (
<span className="ml-1.5 text-xs text-[var(--color-text-muted)]">
({shipmentState.shipped}/{shipmentState.total})
</span>
) : null}
</Button>
);
});
})()}
</div>
{pendingStatus ? (
<div className="flex items-center gap-3 mt-2">
<Button
variant="primary"
disabled={isSavingDeliveryChoice}
onClick={() => {
if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return;
onChangeDeliveryStatus({ onChangeDeliveryStatus({
orderGroupId: order.id, orderGroupId: order.id,
status: statusOption.value, status: pendingStatus.value,
details: pendingStatus.reason ? { reason: pendingStatus.reason, reasonLabel: pendingStatus.reasonLabel } : undefined,
shipmentData: pendingStatus.value === "delivered" && shipmentState ? shipmentState.shipmentData.filter((i) => !i.shipped) : undefined,
}).then((response) => { }).then((response) => {
if (!response.success) { if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус"); setFormMessage(response.error || "Не удалось обновить статус");
} else { } else {
setFormMessage(""); setFormMessage("");
setPendingStatus(null);
} }
}); });
}} }}
disabled={isSavingDeliveryChoice}
> >
{statusOption.label} Сохранить
</Button>
<Button variant="ghost" onClick={() => setPendingStatus(null)}>
Отмена
</Button> </Button>
));
})()}
</div> </div>
) : null}
{formMessage ? ( {formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p> <p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null} ) : null}
@ -1058,13 +1280,16 @@ export const OrderDetailPanel = ({
) : null} ) : null}
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<strong>Номера заказов</strong> <strong>Счета</strong>
{renderList(order.orderNumbers)} {renderList(getAllBillNumbers(order))}
</Panel> </Panel>
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<CollapsibleOrderComposition order={order} /> <CollapsibleOrderComposition order={order} />
</Panel> </Panel>
{userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? (
<DriverShipmentReport shipmentData={order.driver_shipment_data || order.driverShipmentData} />
) : null}
{userRole !== "driver" ? ( {userRole !== "driver" ? (
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<strong>Дополнительные данные</strong> <strong>Дополнительные данные</strong>

View File

@ -7,6 +7,8 @@ import {
getOrderGroupStatusTone, getOrderGroupStatusTone,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
const MAX_VISIBLE_INVOICES = 2;
const buildGroupSummary = (group) => { const buildGroupSummary = (group) => {
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`; const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel]; const parts = [orderCountLabel];
@ -22,11 +24,36 @@ const buildGroupSummary = (group) => {
}; };
const renderOrderNumbers = (group) => { const renderOrderNumbers = (group) => {
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) { const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны"; return "Номера не указаны";
} }
return group.orderNumbers.slice(0, 3).join(" · "); if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return `${visible.join(", ")} +${remaining}`;
};
const renderMobileOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return (
<>
{visible.join(", ")}
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
</>
);
}; };
export const OrdersTable = ({ export const OrdersTable = ({
@ -87,7 +114,7 @@ export const OrdersTable = ({
</div> </div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div> <div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderOrderNumbers(group)}</div> <div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderMobileOrderNumbers(group)}</div>
<div className="mt-3 text-xs text-[var(--color-text-muted)]"> <div className="mt-3 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)} {formatDateTime(group.updatedAt)}
</div> </div>
@ -104,9 +131,8 @@ export const OrdersTable = ({
<table className="min-w-full border-collapse"> <table className="min-w-full border-collapse">
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]"> <thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<tr> <tr>
<th className="px-5 py-4 font-medium">Группа</th> <th className="px-5 py-4 font-medium">Группа / Клиент</th>
<th className="px-5 py-4 font-medium">Клиент</th> <th className="px-5 py-4 font-medium">Счёта</th>
<th className="px-5 py-4 font-medium">Номера</th>
<th className="px-5 py-4 font-medium">Статус</th> <th className="px-5 py-4 font-medium">Статус</th>
<th className="px-5 py-4 font-medium">Водитель</th> <th className="px-5 py-4 font-medium">Водитель</th>
<th className="px-5 py-4 font-medium">Дата доставки</th> <th className="px-5 py-4 font-medium">Дата доставки</th>
@ -125,15 +151,12 @@ export const OrdersTable = ({
> >
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div> <div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{group.groupKey}</div> <div className="mt-1 text-sm text-[var(--color-text-muted)]">
</td> {[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
<td className="px-5 py-4 text-sm">
<div>{group.customerName}</div>
<div className="mt-1 text-[var(--color-text-muted)]">
{group.customerPhone} · {group.customerDate}
</div> </div>
<div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div>
</td> </td>
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]"> <td className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
{renderOrderNumbers(group)} {renderOrderNumbers(group)}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">

View File

@ -100,6 +100,15 @@ export const ORDER_STATUS_META = {
criticalAfterHours: 24, criticalAfterHours: 24,
tone: "accent", tone: "accent",
}, },
"Самовывоз": {
comment: "Клиент выбрал самовывоз. Заказ ожидает выдачи на складе.",
ownerRole: "logistician",
stageKey: "logistics",
stageLabel: getStageLabel("logistics"),
warningAfterHours: 24,
criticalAfterHours: 48,
tone: "accent",
},
"Передан логисту": { "Передан логисту": {
comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.", comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.",
ownerRole: "logistician", 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 = { export const ROLE_TRANSITION_TARGETS = {
manager: ORDER_STATUSES, manager: [...ORDER_STATUSES],
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"], production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
logistician: [ logistician: [
"Новый", "Новый",
@ -243,6 +253,7 @@ export const ROLE_TRANSITION_TARGETS = {
"Доставка согласована", "Доставка согласована",
"Передан логисту", "Передан логисту",
"Назначен водитель", "Назначен водитель",
"Самовывоз",
"Проблема доставки", "Проблема доставки",
"Платное хранение", "Платное хранение",
"Закрыт", "Закрыт",
@ -264,6 +275,7 @@ export const LOGISTICS_STATUSES = [
"Ожидает согласования доставки", "Ожидает согласования доставки",
"Доставка согласована", "Доставка согласована",
"Назначен водитель", "Назначен водитель",
"Самовывоз",
"Проблема доставки", "Проблема доставки",
]; ];

View File

@ -126,9 +126,9 @@ const isSignedOut = () => sessionStorage.getItem(SIGNED_OUT_FLAG) === "1";
/** Clear ALL auth state from storage — called on explicit signOut */ /** Clear ALL auth state from storage — called on explicit signOut */
const clearAllAuthStorage = () => { const clearAllAuthStorage = () => {
// Clear Supabase secureStorage keys from sessionStorage // Clear Supabase secureStorage keys from localStorage
sessionStorage.removeItem("supersam-auth"); localStorage.removeItem("supersam-auth");
sessionStorage.removeItem("supersam-ak"); localStorage.removeItem("supersam-ak");
// Clear local auth cache from localStorage // Clear local auth cache from localStorage
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem("construction-auth-role-hint"); localStorage.removeItem("construction-auth-role-hint");
@ -148,6 +148,8 @@ export const AuthProvider = ({ children }) => {
const [isOtpSent, setIsOtpSent] = useState(false); const [isOtpSent, setIsOtpSent] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [authError, setAuthError] = useState(""); 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 // Ref to prevent getSession from restoring session after explicit signOut
const signedOutRef = useRef(false); const signedOutRef = useRef(false);
@ -157,18 +159,31 @@ export const AuthProvider = ({ children }) => {
return undefined; 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 { const {
data: { subscription }, 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 (!session?.user) {
if (!getSessionResolved && event === "INITIAL_SESSION") {
// Don't set user=null or isSessionLoading=false yet let getSession() decide.
return;
}
setUser(null); setUser(null);
setAuthError(""); setAuthError("");
window.__supersam_user_id__ = null; window.__supersam_user_id__ = null;
setIsSessionLoading(false);
return; return;
} }
// Block session restore if user explicitly signed out (ref or sessionStorage flag) // Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) { if (signedOutRef.current || isSignedOut()) {
setIsSessionLoading(false);
return; return;
} }
@ -182,24 +197,29 @@ export const AuthProvider = ({ children }) => {
} else { } else {
setUser({ ...baseUser, role: baseUser.role || "manager" }); setUser({ ...baseUser, role: baseUser.role || "manager" });
} }
setIsSessionLoading(false);
}); });
} else { } else {
setUser(null); setUser(null);
setIsSessionLoading(false);
} }
setAuthError(""); setAuthError("");
}); });
supabase.auth.getSession().then(({ data, error }) => { supabase.auth.getSession().then(({ data, error }) => {
getSessionResolved = true;
if (error && isStaleRefreshTokenError(error)) { if (error && isStaleRefreshTokenError(error)) {
setUser(null); setUser(null);
setAuthError("Сессия истекла. Войдите заново."); setAuthError("Сессия истекла. Войдите заново.");
clearAllAuthStorage(); clearAllAuthStorage();
void supabase.auth.signOut({ scope: "local" }); void supabase.auth.signOut({ scope: "local" });
setIsSessionLoading(false);
return; return;
} }
// Block session restore if user explicitly signed out (ref or sessionStorage flag) // Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) { if (signedOutRef.current || isSignedOut()) {
setIsSessionLoading(false);
return; return;
} }
@ -212,9 +232,17 @@ export const AuthProvider = ({ children }) => {
} else { } else {
setUser({ ...baseUser, role: baseUser.role || "manager" }); 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(); return () => subscription.unsubscribe();
@ -366,6 +394,7 @@ export const AuthProvider = ({ children }) => {
pendingEmail, pendingEmail,
isOtpSent, isOtpSent,
isLoading, isLoading,
isSessionLoading,
authError, authError,
isDemoMode, isDemoMode,
requestOtp, requestOtp,

View File

@ -102,7 +102,7 @@ export const AppShell = ({
{user.name} · {ROLE_LABELS[user.role] || user.role} {user.name} · {ROLE_LABELS[user.role] || user.role}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0"> <div className="flex items-center gap-1 md:flex-shrink-0">
<NotificationBell <NotificationBell
notifications={notifications} notifications={notifications}
unreadCount={unreadCount} unreadCount={unreadCount}
@ -112,7 +112,7 @@ export const AppShell = ({
/> />
{onOpenGuide ? ( {onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка"> <Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"} ?
</Button> </Button>
) : null} ) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} /> <PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />

View File

@ -2,6 +2,7 @@ import React from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel"; import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference"; import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
@ -130,10 +131,26 @@ export const buildDeliveryConfirmationPayload = ({
slot, slot,
invitation, invitation,
searchDate, searchDate,
}) => ({ 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, deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || undefined,
deliveryTime: slot?.time || invitation?.deliveryTime || undefined, deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
}); };
};
export const buildSelectedSlotFromInvitation = (invitation, slots = []) => { export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
if (!invitation?.deliveryDate) { if (!invitation?.deliveryDate) {
@ -163,6 +180,9 @@ export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) =
: "По этому заказу согласование доставки завершено или передано логисту."; : "По этому заказу согласование доставки завершено или передано логисту.";
}; };
const TAB_DELIVERY = "delivery";
const TAB_PICKUP = "pickup";
export const ClientDeliveryPage = () => { export const ClientDeliveryPage = () => {
const { token } = useParams(); const { token } = useParams();
const [invitation, setInvitation] = React.useState(null); const [invitation, setInvitation] = React.useState(null);
@ -172,6 +192,7 @@ export const ClientDeliveryPage = () => {
const [selectedSlotId, setSelectedSlotId] = React.useState(null); const [selectedSlotId, setSelectedSlotId] = React.useState(null);
const [selectedSlot, setSelectedSlot] = React.useState(null); const [selectedSlot, setSelectedSlot] = React.useState(null);
const [choiceSaved, setChoiceSaved] = React.useState(false); const [choiceSaved, setChoiceSaved] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
const referenceDate = React.useMemo(() => new Date(), [token]); const referenceDate = React.useMemo(() => new Date(), [token]);
React.useEffect(() => { React.useEffect(() => {
@ -195,6 +216,10 @@ export const ClientDeliveryPage = () => {
const loadedInvitation = await fetchDeliveryInvitation(token); const loadedInvitation = await fetchDeliveryInvitation(token);
if (!cancelled) { if (!cancelled) {
setInvitation(loadedInvitation); setInvitation(loadedInvitation);
// If invitation already has deliveryType=pickup, pre-select pickup tab
if (loadedInvitation?.deliveryType === "pickup") {
setActiveTab(TAB_PICKUP);
}
} }
} catch (fetchError) { } catch (fetchError) {
if (!cancelled) { if (!cancelled) {
@ -248,6 +273,11 @@ export const ClientDeliveryPage = () => {
token, token,
deliveryTime: effectiveSelectedSlot.time, deliveryTime: effectiveSelectedSlot.time,
deliveryDate: effectiveSelectedSlot.date, deliveryDate: effectiveSelectedSlot.date,
deliveryType: activeTab,
...(activeTab === TAB_PICKUP ? {
pickupDate: effectiveSelectedSlot.date,
pickupTimeSlot: effectiveSelectedSlot.time,
} : {}),
}); });
const loadedInvitation = await fetchDeliveryInvitation(token); const loadedInvitation = await fetchDeliveryInvitation(token);
setInvitation(loadedInvitation); setInvitation(loadedInvitation);
@ -323,17 +353,57 @@ export const ClientDeliveryPage = () => {
{isChoiceSaved && savedChoiceLabel ? ( {isChoiceSaved && savedChoiceLabel ? (
<Panel className="space-y-2 p-5 sm:p-6"> <Panel className="space-y-2 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2> <h2 className="text-xl font-semibold leading-tight">
{invitation?.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}: {savedChoiceLabel}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
{getInvitationReferenceLabel(invitation)} {getInvitationReferenceLabel(invitation)}
</p> </p>
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор. Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
</p> </p>
</Panel> </Panel>
) : null} ) : null}
{isActiveState && !isChoiceSaved && slots.length ? ( {isActiveState && !isChoiceSaved ? (
<>
{/* Tab switcher */}
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_DELIVERY
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => {
setActiveTab(TAB_DELIVERY);
setSelectedSlotId(null);
setSelectedSlot(null);
setActionMessage("");
}}
>
🚚 Доставка
</button>
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_PICKUP
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => {
setActiveTab(TAB_PICKUP);
setSelectedSlotId(null);
setSelectedSlot(null);
setActionMessage("");
}}
>
🏪 Самовывоз
</button>
</div>
{activeTab === TAB_DELIVERY && slots.length ? (
<DeliverySlotsPicker <DeliverySlotsPicker
slots={slots} slots={slots}
onSelectSlot={handleSlotSelect} onSelectSlot={handleSlotSelect}
@ -341,11 +411,28 @@ export const ClientDeliveryPage = () => {
/> />
) : null} ) : null}
{activeTab === TAB_PICKUP ? (
<PickupSlotsPicker
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
referenceDate={referenceDate}
/>
) : null}
{activeTab === TAB_DELIVERY && !slots.length ? (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
</Panel>
) : null}
</>
) : null}
{isActiveState && !isChoiceSaved ? ( {isActiveState && !isChoiceSaved ? (
<DeliveryChoiceFlow <DeliveryChoiceFlow
invitation={invitation} invitation={invitation}
selectedSlot={effectiveSelectedSlot} selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice} onConfirmChoice={handleSaveChoice}
deliveryType={activeTab}
/> />
) : !isActiveState && !isChoiceSaved ? ( ) : !isActiveState && !isChoiceSaved ? (
<DeliveryStateNotice state={invitationState} /> <DeliveryStateNotice state={invitationState} />

View File

@ -1,5 +1,5 @@
import React from "react"; 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 { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrdersTable } from "../components/orders/OrdersTable"; import { OrdersTable } from "../components/orders/OrdersTable";
@ -34,7 +34,8 @@ const ROLE_SECTION = {
}; };
export const DashboardPage = () => { export const DashboardPage = () => {
const { user, signOut } = useAuth(); const { user, signOut, isSessionLoading } = useAuth();
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const userRole = user?.role; const userRole = user?.role;
@ -117,8 +118,19 @@ export const DashboardPage = () => {
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0]; const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
const isGuideOpen = false; 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) { if (!user) {
return <Navigate to="/login" replace />; return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
}
if (!ALLOWED_DASHBOARD_ROLES.includes(userRole)) {
return <Navigate to="/forbidden" replace />;
} }
const renderActiveSection = () => { const renderActiveSection = () => {

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center px-4">
<Panel className="max-w-lg p-8 text-center">
<h1 className="text-3xl font-semibold">Доступ ограничен</h1>
<p className="mt-3 text-sm text-[var(--color-text-muted)]">
У вас нет прав для просмотра этой страницы. Обратитесь к администратору или войдите с другой учётной записью.
</p>
<div className="mt-6 flex justify-center gap-3">
<Link to="/dashboard">
<Button variant="secondary">На главную</Button>
</Link>
<Link to="/login">
<Button>Войти</Button>
</Link>
</div>
</Panel>
</div>
);
};

View File

@ -1,5 +1,5 @@
import React from "react"; 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 { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { Button } from "../components/UI/Button"; import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
@ -7,13 +7,16 @@ import { useAuth } from "../context/AuthContext";
import { fetchDrivers } from "../services/supabase/userRepository"; import { fetchDrivers } from "../services/supabase/userRepository";
import { useOrderGroups } from "../hooks/useOrderGroups"; import { useOrderGroups } from "../hooks/useOrderGroups";
const ALLOWED_ROLES = ["admin", "mega_admin", "manager", "logistician", "driver"];
export const GroupDetailPage = () => { export const GroupDetailPage = () => {
const { groupId } = useParams(); const { groupId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user } = useAuth(); const { user, isSessionLoading } = useAuth();
const userRole = user?.role; const userRole = user?.role;
// ALL hooks must be called before any early return (Rules of Hooks)
const { const {
allOrderGroups, allOrderGroups,
selectedOrderGroupId, selectedOrderGroupId,
@ -45,11 +48,7 @@ export const GroupDetailPage = () => {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
const order = allOrderGroups.find((g) => g.id === groupId) || // ALL hooks must be called before any early return (Rules of Hooks)
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
null;
// Preserve the tab the user came from when going back
const handleGoBack = React.useCallback(() => { const handleGoBack = React.useCallback(() => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
@ -58,6 +57,25 @@ export const GroupDetailPage = () => {
} }
}, [navigate]); }, [navigate]);
// Wait for session restore before deciding redirect
if (isSessionLoading) {
return null;
}
// Auth guard: redirect to login if not authenticated
if (!user) {
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
}
// Role guard: only allowed roles can access group details
if (!ALLOWED_ROLES.includes(userRole)) {
return <Navigate to="/forbidden" replace />;
}
const order = allOrderGroups.find((g) => g.id === groupId) ||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
null;
return ( return (
<div className="mx-auto w-full max-w-3xl space-y-5"> <div className="mx-auto w-full max-w-3xl space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Navigate } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
import { ROLE_LABELS } from "../constants/roles"; import { ROLE_LABELS } from "../constants/roles";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { demoUsers } from "../data/mockAppData"; import { demoUsers } from "../data/mockAppData";
@ -14,6 +14,9 @@ export const LoginPage = () => {
const [otp, setOtp] = React.useState(""); const [otp, setOtp] = React.useState("");
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [searchParams] = useSearchParams();
const redirectUrl = searchParams.get("redirect") || "/dashboard";
const displayError = error || authError; const displayError = error || authError;
const handleRequestOtp = async () => { const handleRequestOtp = async () => {
@ -60,7 +63,7 @@ export const LoginPage = () => {
}; };
if (user) { if (user) {
return <Navigate to="/dashboard" replace />; return <Navigate to={redirectUrl} replace />;
} }
return ( return (

View File

@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { GroupDetailPage } from "./pages/GroupDetailPage"; import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage"; import { NotFoundPage } from "./pages/NotFoundPage";
import { ForbiddenPage } from "./pages/ForbiddenPage";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@ -24,6 +25,10 @@ export const router = createBrowserRouter([
path: "delivery/:token", path: "delivery/:token",
element: <ClientDeliveryPage />, element: <ClientDeliveryPage />,
}, },
{
path: "forbidden",
element: <ForbiddenPage />,
},
{ {
path: "dashboard", path: "dashboard",
element: <DashboardPage />, element: <DashboardPage />,

View File

@ -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)) { if (isLocalClientInvitationToken(token)) {
const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token); const baseInvitation = getCachedInvitation(token) ?? buildFallbackInvitation(token);
const invitation = cacheInvitation({ const invitation = cacheInvitation({
...baseInvitation, ...baseInvitation,
deliveryType: deliveryType || "delivery",
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
state: "confirmed", state: "confirmed",
@ -242,6 +244,9 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime
p_token: token, p_token: token,
p_delivery_date: deliveryDate, p_delivery_date: deliveryDate,
p_delivery_time: deliveryTime, p_delivery_time: deliveryTime,
p_delivery_type: deliveryType || "delivery",
p_pickup_date: pickupDate || null,
p_pickup_time_slot: pickupTimeSlot || null,
}); });
}; };

View File

@ -12,6 +12,7 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
delivered: "Доставлено", delivered: "Доставлено",
problem: "Проблема", problem: "Проблема",
paid_storage: "Платное хранение", paid_storage: "Платное хранение",
pickup: "Самовывоз",
cancelled: "Отменено", cancelled: "Отменено",
}; };

View File

@ -61,6 +61,26 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone); 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 customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date);
const orderNumbers = toStringArray(row.order_numbers); 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 inferredOrderCount = orderNumbers.length;
const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount); const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
const readyCount = toNumber( const readyCount = toNumber(
@ -140,6 +160,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
readyCount, readyCount,
notReadyCount, notReadyCount,
orderNumbers, orderNumbers,
allBillNumbers,
status: row.status || "draft", status: row.status || "draft",
smsSentAt: row.sms_sent_at || null, smsSentAt: row.sms_sent_at || null,
firstSmsSentAt: row.first_sms_sent_at || null, firstSmsSentAt: row.first_sms_sent_at || null,
@ -168,13 +189,17 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryDateSource: row.delivery_date_source || null, 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: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: rawDeliveryHalfDay, deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime, deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window, deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders, sourceOrders: row.source_orders,
}), }),
orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны", orderNumberSummary: allBillNumbers.length ? allBillNumbers.join(", ") : "Номера не указаны",
searchText: [ searchText: [
row.group_key, row.group_key,
customerName, customerName,
@ -189,6 +214,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryStatus, deliveryStatus,
getOrderGroupDeliveryStatusLabel(deliveryStatus), getOrderGroupDeliveryStatusLabel(deliveryStatus),
orderNumbers.join(" "), orderNumbers.join(" "),
allBillNumbers.join(" "),
row.status, row.status,
getOrderGroupStatusLabel(row.status), getOrderGroupStatusLabel(row.status),
getOrderGroupDeliveryHalfDay({ 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 ({ export const updateOrderGroupDeliveryChoice = async ({
orderGroupId, orderGroupId,
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryType,
pickupDate,
pickupTimeSlot,
}) => { }) => {
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
const updateResult = await client const updateResult = await client
.from("order_groups") .from("order_groups")
.update({ .update({
delivery_status: "agreed", delivery_status: effectiveDeliveryStatus,
delivery_date: deliveryDate, delivery_date: deliveryDate,
delivery_time: deliveryTime, delivery_time: deliveryTime,
delivery_type: deliveryType || "delivery",
pickup_date: deliveryType === "pickup" ? pickupDate : null,
pickup_time_slot: deliveryType === "pickup" ? pickupTimeSlot : null,
delivery_date_source: "manual", delivery_date_source: "manual",
notification_status: "confirmed", notification_status: "confirmed",
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@ -232,7 +267,20 @@ export const updateOrderGroupDeliveryChoice = async ({
const { data, error } = await client const { data, error } = await client
.from("order_groups") .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) .eq("id", orderGroupId)
.single(); .single();
@ -386,7 +434,7 @@ export const fetchOrderGroups = async () => {
const client = requireSupabase(); const client = requireSupabase();
const { data, error } = await client const { data, error } = await client
.from("order_groups") .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 }); .order("updated_at", { ascending: false });
if (error) { if (error) {
@ -408,4 +456,3 @@ export const fetchOrderGroups = async () => {
return group; return group;
}).filter(Boolean); }).filter(Boolean);
}, "Ошибка загрузки групп доставки"); }, "Ошибка загрузки групп доставки");
};

View File

@ -6,31 +6,31 @@ export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey); 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: * Security properties:
* - Uses sessionStorage (dies on tab close, not shared across tabs) * - Tokens are obfuscated with a per-browser random key stored in localStorage
* - Tokens are obfuscated with a per-session random key before storage * - No plaintext tokens in localStorage reduces impact of XSS
* - No plaintext tokens in sessionStorage reduces impact of XSS
* - Auto-clears on detection of tampered/missing data * - 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), * This is NOT as secure as httpOnly cookies (which require server-side SSR),
* but provides significantly better protection than plaintext localStorage: * but is the standard approach for SPA auth with Supabase.
* - 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
*/ */
const STORAGE_KEY = "supersam-auth"; const STORAGE_KEY = "supersam-auth";
const KEY_KEY = "supersam-ak"; const KEY_KEY = "supersam-ak";
function _getKey() { function _getKey() {
let key = sessionStorage.getItem(KEY_KEY); let key = localStorage.getItem(KEY_KEY);
if (!key) { if (!key) {
key = crypto.getRandomValues(new Uint8Array(32)).reduce( key = crypto.getRandomValues(new Uint8Array(32)).reduce(
(s, b) => s + b.toString(16).padStart(2, "0"), (s, b) => s + b.toString(16).padStart(2, "0"),
"" ""
); );
sessionStorage.setItem(KEY_KEY, key); localStorage.setItem(KEY_KEY, key);
} }
return key; return key;
} }
@ -60,15 +60,15 @@ async function _deobfuscate(obfuscated) {
return new TextDecoder().decode(result); return new TextDecoder().decode(result);
} catch { } catch {
// Tampered data clear everything // Tampered data clear everything
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(KEY_KEY); localStorage.removeItem(KEY_KEY);
return ""; return "";
} }
} }
const secureStorage = { const secureStorage = {
getItem: async (key) => { getItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
@ -76,34 +76,34 @@ const secureStorage = {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
return await _deobfuscate(value); return await _deobfuscate(value);
} catch { } catch {
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
return null; return null;
} }
}, },
setItem: async (key, value) => { setItem: async (key, value) => {
let data; let data;
try { try {
const raw = sessionStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
data = raw ? JSON.parse(raw) : {}; data = raw ? JSON.parse(raw) : {};
} catch { } catch {
data = {}; data = {};
} }
data[key] = await _obfuscate(value); data[key] = await _obfuscate(value);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}, },
removeItem: async (key) => { removeItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return; if (!raw) return;
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
delete data[key]; delete data[key];
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} else { } else {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} }
} catch { } catch {
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
}, },
}; };

View File

@ -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"; import { getOrderUpdateForInboundAction } from "./workflow.ts";
export type ProviderName = "telegram" | "vk" | "messenger_max"; export type ProviderName = "telegram" | "vk" | "messenger_max";

View File

@ -120,15 +120,25 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
}; };
export const buildDefaultDatedAvailableSlots = (now = new Date()) => { 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 addDays = (date: Date, days: number) => {
const next = new Date(date); const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days); next.setUTCDate(next.getUTCDate() + days);
return next; return next;
}; };
const firstDay = formatIsoDate(addDays(now, 1)); const firstDay = formatCrimeaDate(addDays(now, 1));
const secondDay = formatIsoDate(addDays(now, 2)); const secondDay = formatCrimeaDate(addDays(now, 2));
return [ return [
`${firstDay}, Первая половина дня`, `${firstDay}, Первая половина дня`,

View File

@ -1,399 +1,172 @@
type CorsMode = "public" | "integration" | "webhook"; import { createClient } from 'npm:@supabase/supabase-js@2';
type JsonBodyOptions = { const ALLOWED_ORIGINS = [
maxBytes: number; 'https://supa.supersamsev.ru',
errorMessage?: string; '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<string, string>) {
const headers: Record<string, string> = { '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<string> {
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<T> {
body: T;
}
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
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; scope: string;
key: string; key: string;
maxCount: number; maxCount: number;
windowSeconds: number; windowSeconds: number;
blockSeconds?: number; blockSeconds: number;
}; }
type RateLimitResult = { class RateLimitError extends Error {
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 {
status: number; status: number;
constructor(message: string, status: number) {
constructor(status: number, message: string) {
super(message); super(message);
this.status = status; this.status = status;
this.name = "HttpError";
} }
} }
export const jsonResponse = ( export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
body: unknown, const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
status = 200, const tableName = 'rate_limits';
headers: HeadersInit = {}, const now = new Date();
) =>
new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
export const getCorsHeaders = (request: Request, mode: CorsMode) => { const { data: blocked } = await supabase
const origin = getRequestOrigin(request); .from(tableName)
const allowedOrigins = resolveAllowedOrigins(mode); .select('blocked_until')
.eq('scope', scope)
.eq('rate_key', key)
.gt('blocked_until', now.toISOString())
.limit(1);
if (!origin) { if (blocked && blocked.length > 0) {
if (allowedOrigins.length === 0) { throw new RateLimitError('Too many requests. Please try again later.', 429);
return null;
} }
return { const windowStart = new Date(now.getTime() - windowSeconds * 1000);
"Access-Control-Allow-Origin": "*", const { data: recent, error } = await supabase
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", .from(tableName)
"Access-Control-Allow-Methods": "GET, POST, OPTIONS", .select('id, count')
"Access-Control-Max-Age": "86400", .eq('scope', scope)
Vary: "Origin", .eq('rate_key', key)
} satisfies Record<string, string>; .gte('window_start', windowStart.toISOString());
}
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<string, string>;
};
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 <T extends Record<string, unknown>>(
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<string, unknown>,
) => 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,
});
if (error) { if (error) {
throw error; console.error('Rate limit check error:', error);
return;
} }
if (!data?.allowed) { const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
throw new HttpError(429, "Too many requests");
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,
});
}
}

View File

@ -24,6 +24,9 @@ type ConfirmBody = {
token?: string; token?: string;
deliveryDate?: string; deliveryDate?: string;
deliveryTime?: string; deliveryTime?: string;
deliveryType?: string;
pickupDate?: string;
pickupTimeSlot?: string;
}; };
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
@ -36,6 +39,7 @@ const resolveRequestedSlot = (
}, },
body: ConfirmBody, body: ConfirmBody,
) => { ) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
@ -43,6 +47,11 @@ const resolveRequestedSlot = (
return null; 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 slotLabel = `${deliveryDate}, ${deliveryTime}`;
const availableSlots = invitation.available_slots || []; const availableSlots = invitation.available_slots || [];
@ -50,7 +59,7 @@ const resolveRequestedSlot = (
return null; return null;
} }
return { deliveryDate, deliveryTime }; return { deliveryDate, deliveryTime, deliveryType };
}; };
Deno.serve(async (request) => { Deno.serve(async (request) => {
@ -127,6 +136,9 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); 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) { if (invitation.order_group_id) {
const { data: currentGroup, error: groupError } = await supabase const { data: currentGroup, error: groupError } = await supabase
.from("order_groups") .from("order_groups")
@ -177,15 +189,23 @@ Deno.serve(async (request) => {
throw invitationUpdateError; throw invitationUpdateError;
} }
const { error: groupUpdateError } = await supabase const groupUpdateData: Record<string, unknown> = {
.from("order_groups") delivery_status: effectiveDeliveryStatus,
.update({
delivery_status: "agreed",
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
notification_status: "confirmed", notification_status: "confirmed",
updated_at: new Date().toISOString(), 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(groupUpdateData)
.eq("id", invitation.order_group_id); .eq("id", invitation.order_group_id);
if (groupUpdateError) { if (groupUpdateError) {
@ -197,10 +217,13 @@ Deno.serve(async (request) => {
order_group_id: invitation.order_group_id, order_group_id: invitation.order_group_id,
action: "client_confirmed", action: "client_confirmed",
old_value: currentGroup.delivery_status, old_value: currentGroup.delivery_status,
new_value: "agreed", new_value: effectiveDeliveryStatus,
details: { details: {
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
source: "auto", source: "auto",
}, },
}); });
@ -215,6 +238,9 @@ Deno.serve(async (request) => {
delivery_invitation_id: invitation.id, delivery_invitation_id: invitation.id,
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, 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, ok: true,
orderGroupId: invitation.order_group_id, orderGroupId: invitation.order_group_id,
deliveryStatus: "agreed", deliveryStatus: effectiveDeliveryStatus,
}, },
200, 200,
corsHeaders, corsHeaders,
@ -314,6 +340,9 @@ Deno.serve(async (request) => {
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, 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: { payload: {
delivery_date: requestedSlot.deliveryDate, delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime, delivery_time: requestedSlot.deliveryTime,
delivery_type: deliveryType,
pickup_date: body.pickupDate || null,
pickup_time_slot: body.pickupTimeSlot || null,
}, },
}); });

View File

@ -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<typeof jose.createRemoteJWKSet> | 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 <token>"
*
* @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<boolean> {
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<boolean> {
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<boolean> {
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' },
})
}
})

View File

@ -1,4 +1,4 @@
import { createAnonClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/security.ts";
import { import {
getClientIp, getClientIp,
getCorsHeaders, getCorsHeaders,
@ -14,6 +14,17 @@ const MAX_BODY_BYTES = 8 * 1024;
const isValidEmail = (value: string) => const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); /^[^\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) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return preflightResponse(request, "public"); return preflightResponse(request, "public");
@ -38,7 +49,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
} }
const supabase = createAnonClient(); const supabase = createServiceClient();
const emailHash = await hashText(email); const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));
@ -50,15 +61,50 @@ Deno.serve(async (request) => {
blockSeconds: 1800, 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, email,
options: { name: userName,
shouldCreateUser: false, role: userRole,
}, otp_code: otp,
otp_code_hash: otpCodeHash,
ip_address: clientIp,
user_agent: userAgent,
verified: false,
}); });
if (error) { if (insertError) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); console.error("Failed to insert OTP:", insertError);
return jsonResponse({ ok: false, error: "Failed to generate OTP" }, 500, corsHeaders);
} }
return jsonResponse({ ok: true }, 200, corsHeaders); return jsonResponse({ ok: true }, 200, corsHeaders);

View File

@ -1,4 +1,4 @@
import { createAnonClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/security.ts";
import { import {
getClientIp, getClientIp,
getCorsHeaders, getCorsHeaders,
@ -7,10 +7,10 @@ import {
preflightResponse, preflightResponse,
readJsonBody, readJsonBody,
requireRateLimit, requireRateLimit,
requireSameOrigin,
} from "../_shared/security.ts"; } from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024; const MAX_BODY_BYTES = 8 * 1024;
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
const isValidEmail = (value: string) => const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
@ -29,19 +29,6 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); 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 { try {
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
maxBytes: MAX_BODY_BYTES, maxBytes: MAX_BODY_BYTES,
@ -57,7 +44,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
} }
const supabase = createAnonClient(); const supabase = createServiceClient();
const emailHash = await hashText(email); const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));
@ -69,21 +56,118 @@ Deno.serve(async (request) => {
blockSeconds: 1800, 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, email,
token: otp,
type: "email",
}); });
if (error) { if (linkError || !linkData) {
return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); 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( return jsonResponse(
{ {
ok: true, ok: true,
session: data.session || null, session: session || null,
user: data.session?.user || null, user: user || null,
}, },
200, 200,
corsHeaders, corsHeaders,

View File

@ -0,0 +1,83 @@
# Edge Functions
## `chatbot-webhook`
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
Требует подпись `X-Signature` или `Authorization: Bearer <INTEGRATION_API_KEY>`, а также
ограничивает частоту входящих событий.
Пример вызова:
```bash
curl -X POST \
'https://<project>.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`
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.

View File

@ -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<string, unknown>;
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<string, unknown>,
): NormalizedChatEvent => {
const payload = (body.payload as Record<string, unknown>) || {};
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;

View File

@ -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();
});
});

View File

@ -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<string, unknown>;
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<string, unknown>;
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,
};
};

View File

@ -0,0 +1,30 @@
type IntegrationEventPayload = {
order_id?: string | null;
event_type: string;
direction?: "inbound" | "outbound" | "internal";
source?: string;
status?: string;
payload?: Record<string, unknown>;
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;
}
};

View File

@ -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<string, string>) {
const headers: Record<string, string> = { '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<string> {
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<T> {
body: T;
}
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
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<typeof createClient>, 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,
});
}
}

View File

@ -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: "Отправлено клиенту",
});
});
});

View File

@ -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;
}
};

View File

@ -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<ProviderName>(["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<Record<string, unknown>>(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,
);
}
});

View File

@ -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<ConfirmBody>(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,
);
}
});

View File

@ -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<CreateInvitationBody>(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,
);
}
});

View File

@ -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<InvitationBody>(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,
);
}
});

View File

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
}
}

View File

@ -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<typeof jose.createRemoteJWKSet> | 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 <token>"
*
* @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<boolean> {
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<boolean> {
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<boolean> {
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' },
})
}
})

View File

@ -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<string, unknown>;
};
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<ReportBody>(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,
);
}
});

View File

@ -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,
);
}
});

View File

@ -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<ProviderName, string | undefined> = {
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,
);
}
});

View File

@ -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<TransferBody>(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,
);
}
});

View File

@ -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<typeof createServiceClient>,
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<UpdateDeliveryChoiceBody>(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,
);
}
});

View File

@ -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,
);
}
});