Ссылка на согласование
diff --git a/src/components/orders/SmsStatusCard.jsx b/src/components/orders/SmsStatusCard.jsx
index 3f56c95..1b84acc 100644
--- a/src/components/orders/SmsStatusCard.jsx
+++ b/src/components/orders/SmsStatusCard.jsx
@@ -22,6 +22,8 @@ const NOTIF_LABELS = {
paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик",
+ confirmed: "Клиент согласовал дату",
+ completed: "Завершено",
};
const NOTIF_TONES = {
@@ -36,6 +38,8 @@ const NOTIF_TONES = {
paid_storage_sending: "info",
paid_storage_sent: "accent",
draft: "neutral",
+ confirmed: "accent",
+ completed: "neutral",
};
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -173,11 +177,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
return (
-
+
📱 SMS-уведомления
-
- {NOTIF_LABELS[notifStatus] || notifStatus}
-
{/* Timeline */}
@@ -188,9 +189,9 @@ export const SmsStatusCard = ({ order, userRole }) => {
1-е SMS
{hasFirstSms ? (
-
{fmtTime(firstSmsAt)} ✓ доставлено
+
{fmtTime(firstSmsAt)} ✓ получено клиентом
) : hasSmsSent && notifStatus === "sms_sending" ? (
-
{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…
+
{fmtTime(smsSentAt)} · отправлено, ждём ответ оператора…
) : notifStatus === "link_ready" ? (
в очереди на отправку
) : (
@@ -205,7 +206,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
2-е SMS
{hasSecondSms ? (
-
{fmtTime(secondSmsAt)}
+
{fmtTime(secondSmsAt)} ✓ получено клиентом
) : notifStatus === "first_sms_sent" && countdown ? (
отправка через {countdown}
@@ -260,6 +261,23 @@ export const SmsStatusCard = ({ order, userRole }) => {
)}
+ {/* Client page access info */}
+ {(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
+
+ 👁 Клиент открывал страницу согласования
+ {order.invitationAccessCount || 1} раз
+ {(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
+
+ · последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
+
+ )}
+
+ ) : (
+
+ 📭 Клиент ещё не открывал страницу согласования
+
+ )}
+
{/* Restart buttons */}
{canManage && (
diff --git a/src/components/orders/StatusActionPanel.jsx b/src/components/orders/StatusActionPanel.jsx
index ba2fce0..0664df5 100644
--- a/src/components/orders/StatusActionPanel.jsx
+++ b/src/components/orders/StatusActionPanel.jsx
@@ -4,8 +4,6 @@ import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
-const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
-
const StatusActionPanel = ({
order,
userRole,
@@ -18,6 +16,48 @@ const StatusActionPanel = ({
}
const currentStatus = order.deliveryStatus || order.delivery_status;
+ const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
+
+ // Check delivery schedule
+ const hasDeliveryDate = !!(order.deliveryDate || order.customerDate);
+ const hasDeliveryHalfDay = !!(order.deliveryTime || order.deliveryHalfDay || order.delivery_time || order.delivery_half_day);
+ const hasDeliverySchedule = hasDeliveryDate && hasDeliveryHalfDay;
+ const hasDriver = !!order.assignedDriverId;
+
+ // Smart hints: show actual state instead of generic "do X"
+ const getHint = (statusValue) => {
+ if (statusValue === "agreed") {
+ if (hasDeliverySchedule) return "Дата доставки уже согласована";
+ return "Согласуйте дату доставки выше";
+ }
+ if (statusValue === "driver_assigned") {
+ if (hasDriver) return `Водитель уже назначен: ${order.assignedDriverName || ""}`.trim();
+ return "Назначьте водителя из списка выше";
+ }
+ return "";
+ };
+
+ const allStatuses = isPickup
+ ? [
+ { value: "pending_confirmation", label: "Ожидает согласования" },
+ { value: "agreed", label: "Согласовано" },
+ { value: "driver_assigned", label: "Назначен водитель" },
+ { value: "picked_up", label: "Вывезено", primary: true, requiresSchedule: true, requiresDriver: false },
+ { value: "delivered", label: "Доставлено", mismatch: true, requiresSchedule: true },
+ { value: "requires_address", label: "Требуется адрес" },
+ { value: "problem", label: "Проблема" },
+ { value: "cancelled", label: "Отменено" },
+ ]
+ : [
+ { value: "pending_confirmation", label: "Ожидает согласования" },
+ { value: "agreed", label: "Согласовано" },
+ { value: "driver_assigned", label: "Назначен водитель" },
+ { value: "delivered", label: "Доставлено", primary: true, requiresSchedule: true, requiresDriver: true },
+ { value: "picked_up", label: "Вывезено", mismatch: true, requiresSchedule: true },
+ { value: "requires_address", label: "Требуется адрес" },
+ { value: "problem", label: "Проблема" },
+ { value: "cancelled", label: "Отменено" },
+ ];
return (
@@ -27,41 +67,71 @@ const StatusActionPanel = ({
Измените статус, если водитель забыл обновить или нужна корректировка.
+
+ {/* Status indicators: show current state clearly */}
- {[
- { value: "pending_confirmation", label: "Ожидает согласования", manual: true },
- { value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
- { value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
- { value: "loaded", label: "Загружено", manual: true },
- { value: "delivered", label: "Доставлено", manual: true },
- { value: "picked_up", label: "Вывезено", manual: true },
- { value: "requires_address", label: "Требуется адрес", manual: true },
- { value: "problem", label: "Проблема", manual: true },
- { value: "cancelled", label: "Отменено", manual: true },
- ].map((statusOption) => {
+ {hasDeliverySchedule && (
+ ✓ Дата согласована
+ )}
+ {hasDriver && (
+ ✓ Водитель: {order.assignedDriverName || "назначен"}
+ )}
+ {!hasDeliverySchedule && (
+ ⚠ Дата не указана
+ )}
+ {!hasDriver && !isPickup && (
+ ⚠ Водитель не назначен
+ )}
+
+
+
+ {allStatuses.map((statusOption) => {
const isCurrent = currentStatus === statusOption.value;
- const isClickable = statusOption.manual !== false && !isCurrent;
+ const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
+ const blockedByDriver = statusOption.requiresDriver !== false && !hasDriver;
+ const hint = getHint(statusOption.value);
return (
-
-
-
+
);
})}
+ {!hasDeliverySchedule && (
+
+ ⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
+
+ )}
);
};
-export { StatusActionPanel };
\ No newline at end of file
+export { StatusActionPanel };
diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js
index 15f1491..dbad65b 100644
--- a/src/constants/deliveryWorkflow.js
+++ b/src/constants/deliveryWorkflow.js
@@ -240,10 +240,10 @@ export const ORDER_STATUS_TRANSITIONS = {
"Ожидает согласования доставки": ["Доставка согласована", "Самовывоз", "Требуется адрес", "Проблема доставки", "Отменён"],
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки", "Самовывоз", "Требуется адрес"],
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
- "Назначен водитель": ["Загружен", "Проблема доставки"],
+ "Назначен водитель": ["Доставлен", "Проблема доставки"],
Загружен: ["Доставлен", "Проблема доставки"],
"В пути": ["Доставлен", "Проблема доставки"],
- Доставлен: ["Закрыт"],
+ Доставлен: ["Закрыт", "Назначен водитель"],
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
"Самовывоз": ["Доставка согласована", "Закрыт", "Отменён", "Платное хранение"],
@@ -270,7 +270,7 @@ export const ROLE_TRANSITION_TARGETS = {
"Закрыт",
"Отменён",
],
- driver: ["Загружен", "Доставлен", "Проблема доставки"],
+ driver: ["Доставлен", "Проблема доставки"],
admin: ORDER_STATUSES,
};
@@ -291,7 +291,7 @@ export const LOGISTICS_STATUSES = [
"Проблема доставки",
];
-export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"];
+export const DRIVER_STATUSES = ["Назначен водитель", "Доставлен"];
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";
diff --git a/src/fontSettings.css b/src/fontSettings.css
new file mode 100644
index 0000000..6f71344
--- /dev/null
+++ b/src/fontSettings.css
@@ -0,0 +1,101 @@
+/* Font size scale variables — applied by FontSettingsContext */
+:root {
+ --fs-scale-table: 1.0;
+ --fs-scale-card: 1.0;
+ --fs-scale-nav: 1.0;
+ --fs-scale-heading: 1.0;
+ --fs-scale-body: 1.0;
+ --fs-scale-small: 1.0;
+}
+
+/* Slider styling */
+.fs-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 6px;
+ border-radius: 3px;
+ outline: none;
+ cursor: pointer;
+}
+
+.fs-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ border: 3px solid var(--color-surface-strong);
+ cursor: pointer;
+ transition: transform 120ms ease;
+}
+
+.fs-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+}
+
+.fs-slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ border: 3px solid var(--color-surface-strong);
+ cursor: pointer;
+ border: none;
+}
+
+/* ── Zone overrides ──────────────────────────────────────────────────────
+ .fs-zone-* wrappers override Tailwind text-* classes inside them.
+ Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
+ This lets us scale fonts without patching every component.
+────────────────────────────────────────────────────────────────────────── */
+
+/* Table zone */
+.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
+/* Arbitrary pixel sizes used in tables */
+.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
+.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
+
+/* Card zone */
+.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
+.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
+
+/* Nav zone */
+.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
+.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
+.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
+.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
+
+/* Heading zone */
+.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
+.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
+
+/* Small text zone */
+.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
+.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
+.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
+
+/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
+.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
+.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
+.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
+.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
+.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
\ No newline at end of file
diff --git a/src/hooks/useOrderGroups.js b/src/hooks/useOrderGroups.js
index 1cecf6c..d1d32a1 100644
--- a/src/hooks/useOrderGroups.js
+++ b/src/hooks/useOrderGroups.js
@@ -11,11 +11,21 @@ import { getErrorMessage } from "../utils/deliveryUtils";
export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []);
- const [filters, setFilters] = React.useState({
- query: "",
- displayStatus: "all",
- deliveryType: "",
+ const FILTERS_STORAGE_KEY = "supersam_order_filters";
+ const [filters, setFilters] = React.useState(() => {
+ try {
+ const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
+ if (saved) return JSON.parse(saved);
+ } catch (e) {}
+ return { query: "", displayStatus: "all", deliveryType: "" };
});
+
+ // Persist filters to localStorage on every change
+ React.useEffect(() => {
+ try {
+ localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
+ } catch (e) {}
+ }, [filters]);
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx
index 39dc38f..12e426e 100644
--- a/src/pages/DashboardPage.jsx
+++ b/src/pages/DashboardPage.jsx
@@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
+import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
import { useAuth } from "../context/AuthContext";
@@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
+ { key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
];
// ── Role → Default Section Map ─────────────────────────────────────────────
@@ -137,6 +139,7 @@ export const DashboardPage = () => {
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
+ { key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
]
: userRole === "logistician"
? [
@@ -178,6 +181,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
if (activeSection === "action_log") return
;
if (activeSection === "suggestions") return
;
if (activeSection === "sms_campaign") return
;
+ if (activeSection === "schedule") return
;
if (isLoading) {
if (userRole === "driver") {
diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js
index 2dd555d..ce748ce 100644
--- a/src/services/orderGroupViews.js
+++ b/src/services/orderGroupViews.js
@@ -39,7 +39,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"paid_storage",
];
-export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
+export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
const HALF_DAY_LABELS = {
morning: "Первая половина дня",
@@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
export const getOrderGroupDeliveryStatusLabel = (status) =>
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
+// Status values that represent a delivery state (not SMS/notification state)
+const DELIVERY_STATUS_VALUES = new Set([
+ "pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
+ "delivered", "picked_up", "pickup", "requires_address", "problem",
+ "cancelled", "paid_storage", "address_required", "manual_confirmation_required",
+]);
+
export const getOrderGroupDisplayStatusLabel = (group) => {
- const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
+ const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
+ const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
const isManualRequired = notificationStatus === "manual_required";
@@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return "Требуется ручное управление";
}
+ // Primary: status column (now synced with delivery_status)
+ if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
+ return getOrderGroupDeliveryStatusLabel(statusCol);
+ }
+
+ // Fallback: delivery_status (for pending/manual_confirmation groups)
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
}
@@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return notificationLabel;
}
- return getOrderGroupStatusLabel(group?.status);
+ return getOrderGroupStatusLabel(statusCol);
};
export const getOrderGroupDisplayStatusValue = (group) => {
- const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
+ const statusCol = group?.status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
+ const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
// Unify manual_required into a single bucket regardless of delivery_status detail
const isManualRequired = notificationStatus === "manual_required";
@@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
return "status:manual_required";
}
+ // Primary: status column (now synced with delivery_status)
+ if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
+ return `delivery:${statusCol}`;
+ }
+
+ // Fallback: delivery_status
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return `delivery:${deliveryStatus}`;
}
- return `status:${group?.status || "unknown"}`;
+ return `status:${statusCol || "unknown"}`;
};
export const isOrderGroupVisibleToDriver = (group) => {
@@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => {
export const getOrderGroupStatusTone = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
+ const statusCol = group?.status;
// Highlight groups with delivery problems
if (group?.hasDeliveryProblem) return "warning";
+ // Priority: if status column already holds a delivery-level value, use it
+ if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
+ return getOrderGroupDeliveryStatusTone(statusCol);
+ }
+
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
return getOrderGroupDeliveryStatusTone(deliveryStatus);
}
diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js
index 2131df0..f08c34a 100644
--- a/src/services/supabase/orderGroupRepository.js
+++ b/src/services/supabase/orderGroupRepository.js
@@ -142,12 +142,23 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
- const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
- if (m) return m[1].trim();
- // 2) known city name anywhere in address (case-insensitive)
+ // Word markers (город/село) require space after to avoid matching "Стройгородок"
+ // Dot markers (г./гор./etc) allow zero space (г.Ялта)
+ const m = addr.match(/(?:г\.\s*|гор\.\s*|пос\.\s*|с\.\s*|дер\.\s*|пгт\.\s*|город\s+|село\s+|г\s+)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\s]|$)/i);
+ if (m) {
+ const candidate = m[1].trim();
+ for (const city of CRIMEAN_CITIES) {
+ if (city.toLowerCase() === candidate.toLowerCase()) return city;
+ }
+ // Not a known city — continue to step 2
+ }
+ // 2) known city name via custom word boundary (JS \b doesn't work with Cyrillic)
+ // City must be preceded by start/comma/space/dot and followed by comma/space/end
const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) {
- if (lower.includes(city.toLowerCase())) return city;
+ const esc = city.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp("(^|[,.\\s])" + esc + "(?=[,\\s]|$)", "i");
+ if (re.test(lower)) return city;
}
// 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
@@ -460,15 +471,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
return safeSupabaseCall(async () => {
const client = requireSupabase();
- // Fetch current status before any update (needed for audit log)
+ // Fetch current status before any update (needed for audit log + status sync)
const { data: current, error: fetchCurrentError } = await client
.from("order_groups")
- .select("delivery_status")
+ .select("delivery_status, delivery_type")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) throw fetchCurrentError;
+ // Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
+ const deliveryType = current.delivery_type || "delivery";
+ const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
+ : (deliveryType === "delivery" && status === "picked_up") ? "delivered"
+ : (status === "delivered") ? "delivered"
+ : status;
+
// Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage.
@@ -479,6 +497,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
+ status: statusSync,
paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
@@ -490,6 +509,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
.from("order_groups")
.update({
delivery_status: status,
+ status: statusSync,
paid_storage_at: null,
updated_at: new Date().toISOString(),
})
diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts
index a7fc938..9458ce3 100644
--- a/supabase/functions/_shared/delivery-invitations.ts
+++ b/supabase/functions/_shared/delivery-invitations.ts
@@ -119,9 +119,26 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
};
-export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
+export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
const CRIMEA_TZ = "Europe/Simferopol";
+ // Fetch delivery days from business_schedule_settings
+ let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
+ if (supabaseClient) {
+ try {
+ const { data } = await supabaseClient
+ .from("business_schedule_settings")
+ .select("delivery_days")
+ .eq("id", 1)
+ .single();
+ if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
+ deliveryDays = data.delivery_days;
+ }
+ } catch {
+ // Silent fallback to defaults
+ }
+ }
+
const formatCrimeaDate = (date: Date) => {
return new Intl.DateTimeFormat("en-CA", {
timeZone: CRIMEA_TZ,
@@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
return next;
};
- // Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
- const isSunday = (date: Date) => date.getUTCDay() === 0;
+ // Check if date is an allowed delivery day
+ // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
+ // deliveryDays uses 1=Mon ... 7=Sun
+ const isAllowedDay = (date: Date) => {
+ const dow = date.getUTCDay();
+ const dayNum = dow === 0 ? 7 : dow;
+ return deliveryDays.includes(dayNum);
+ };
const getNextWorkday = (date: Date) => {
let next = addDays(date, 1);
- while (isSunday(next)) {
+ while (!isAllowedDay(next)) {
next = addDays(next, 1);
}
return next;
diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts
index 33b29eb..65a5d78 100644
--- a/supabase/functions/confirm-delivery-choice/index.ts
+++ b/supabase/functions/confirm-delivery-choice/index.ts
@@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
-const isWeekendDate = (value: string) => {
+// Fetch delivery days from business_schedule_settings
+const getDeliveryDays = async (supabaseClient: any): Promise
=> {
+ try {
+ const { data } = await supabaseClient
+ .from("business_schedule_settings")
+ .select("delivery_days")
+ .eq("id", 1)
+ .single();
+ if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
+ return data.delivery_days as number[];
+ }
+ } catch {
+ // Silent fallback
+ }
+ return [1, 2, 3, 4, 5]; // Default: Mon-Fri
+};
+
+const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
if (!isValidDate(value)) return false;
const date = new Date(`${value}T12:00:00Z`);
- const weekday = date.getUTCDay();
- return weekday === 0; // 0=Sunday — never allow Sunday delivery
+ const dow = date.getUTCDay();
+ // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
+ // deliveryDays uses 1=Mon ... 7=Sun
+ const dayNum = dow === 0 ? 7 : dow;
+ return deliveryDays.includes(dayNum);
};
const resolveRequestedSlot = (
@@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
+ deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
- // Reject Sunday for delivery (business rule: never deliver on Sunday)
- if (isWeekendDate(deliveryDate)) {
+ // Reject non-delivery days for delivery (business schedule)
+ if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
+ const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
- const requestedSlot = resolveRequestedSlot(invitation, body);
+ const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
return jsonResponse(
{
diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts
new file mode 100644
index 0000000..b2d1a10
--- /dev/null
+++ b/supabase/functions/manage-users/index.ts
@@ -0,0 +1,165 @@
+import {
+ createServiceClient,
+ getCorsHeaders,
+ jsonResponse,
+ preflightResponse,
+ readJsonBody,
+} from "../_shared/security.ts";
+
+const MAX_BODY_BYTES = 8 * 1024;
+
+const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
+
+const isValidEmail = (value: string) =>
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
+
+/**
+ * Verify the caller's JWT and return their role from public.users.
+ * Returns null if not authorized.
+ */
+async function getCallerRole(request: Request): Promise {
+ const authHeader = request.headers.get("authorization") || "";
+ const token = authHeader.replace(/^Bearer\s+/i, "").trim();
+ if (!token) return null;
+
+ const supabase = createServiceClient();
+ const { data: userData, error: userError } = await supabase.auth.getUser(token);
+ if (userError || !userData?.user) return null;
+
+ const userId = userData.user.id;
+ const { data: userRow, error: roleError } = await supabase
+ .from("users")
+ .select("roles(name)")
+ .eq("id", userId)
+ .maybeSingle();
+ if (roleError || !userRow) return null;
+
+ return userRow.roles?.name || null;
+}
+
+Deno.serve(async (request) => {
+ if (request.method === "OPTIONS") {
+ return preflightResponse(request, "private");
+ }
+
+ const corsHeaders = getCorsHeaders(request, "private");
+ if (!corsHeaders) {
+ return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
+ }
+
+ try {
+ // ── Authorization ──
+ const callerRole = await getCallerRole(request);
+ if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
+ return jsonResponse(
+ { ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
+ 403,
+ corsHeaders,
+ );
+ }
+
+ const supabase = createServiceClient();
+
+ // ── POST: create new user ──
+ if (request.method === "POST") {
+ const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
+ request,
+ { maxBytes: MAX_BODY_BYTES },
+ );
+
+ const email = String(body.email || "").trim().toLowerCase();
+ const name = String(body.name || "").trim();
+ const role = String(body.role || "").trim().toLowerCase();
+
+ if (!email || !isValidEmail(email)) {
+ return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
+ }
+ if (!name) {
+ return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
+ }
+ if (!role) {
+ return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
+ }
+
+ // Check if email already exists in public.users
+ const { data: existingUser } = await supabase
+ .from("users")
+ .select("id")
+ .eq("email", email)
+ .maybeSingle();
+ if (existingUser) {
+ return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
+ }
+
+ // Create auth user — trigger handle_new_user will auto-insert into public.users
+ // using user_metadata.role and user_metadata.name
+ const { data: authData, error: authError } = await supabase.auth.admin.createUser({
+ email,
+ email_confirm: true,
+ user_metadata: { name, role },
+ });
+
+ if (authError) {
+ console.error("auth.createUser error:", authError);
+ return jsonResponse(
+ { ok: false, error: "Ошибка создания пользователя: " + authError.message },
+ 500,
+ corsHeaders,
+ );
+ }
+
+ const newUserId = authData.user.id;
+
+ return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
+ }
+
+ // ── DELETE: remove user ──
+ if (request.method === "DELETE") {
+ const url = new URL(request.url);
+ const userId = url.searchParams.get("id");
+ if (!userId) {
+ return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
+ }
+
+ // Get user info before deletion
+ const { data: userRow } = await supabase
+ .from("users")
+ .select("id, email, name")
+ .eq("id", userId)
+ .maybeSingle();
+
+ if (!userRow) {
+ return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
+ }
+
+ // Delete auth user (FK ON DELETE CASCADE will remove public.users row)
+ const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
+ if (authDeleteError) {
+ console.error("auth.deleteUser error:", authDeleteError);
+ // Try deleting public.users directly as fallback
+ const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
+ if (dbDeleteError) {
+ return jsonResponse(
+ { ok: false, error: "Ошибка удаления: " + authDeleteError.message },
+ 500,
+ corsHeaders,
+ );
+ }
+ }
+
+ return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
+ }
+
+ return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
+ } catch (error) {
+ if (error instanceof Error && "status" in error) {
+ const httpError = error as { status: number; message: string };
+ return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
+ }
+ return jsonResponse(
+ { ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
+ 500,
+ corsHeaders,
+ );
+ }
+});
\ No newline at end of file
diff --git a/volumes/functions/_shared/delivery-invitations.ts b/volumes/functions/_shared/delivery-invitations.ts
index 70e7ade..9458ce3 100644
--- a/volumes/functions/_shared/delivery-invitations.ts
+++ b/volumes/functions/_shared/delivery-invitations.ts
@@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
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);
+export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
+ const CRIMEA_TZ = "Europe/Simferopol";
+
+ // Fetch delivery days from business_schedule_settings
+ let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
+ if (supabaseClient) {
+ try {
+ const { data } = await supabaseClient
+ .from("business_schedule_settings")
+ .select("delivery_days")
+ .eq("id", 1)
+ .single();
+ if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
+ deliveryDays = data.delivery_days;
+ }
+ } catch {
+ // Silent fallback to defaults
+ }
+ }
+
+ const formatCrimeaDate = (date: Date) => {
+ return new Intl.DateTimeFormat("en-CA", {
+ timeZone: CRIMEA_TZ,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(date);
+ };
+
const addDays = (date: Date, days: number) => {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
};
- const firstDay = formatIsoDate(addDays(now, 1));
- const secondDay = formatIsoDate(addDays(now, 2));
+ // Check if date is an allowed delivery day
+ // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
+ // deliveryDays uses 1=Mon ... 7=Sun
+ const isAllowedDay = (date: Date) => {
+ const dow = date.getUTCDay();
+ const dayNum = dow === 0 ? 7 : dow;
+ return deliveryDays.includes(dayNum);
+ };
+
+ const getNextWorkday = (date: Date) => {
+ let next = addDays(date, 1);
+ while (!isAllowedDay(next)) {
+ next = addDays(next, 1);
+ }
+ return next;
+ };
+
+ const firstWorkday = getNextWorkday(now);
+ const secondWorkday = getNextWorkday(firstWorkday);
+
+ const firstDay = formatCrimeaDate(firstWorkday);
+ const secondDay = formatCrimeaDate(secondWorkday);
return [
`${firstDay}, Первая половина дня`,
diff --git a/volumes/functions/check-sms-status/index.ts b/volumes/functions/check-sms-status/index.ts
new file mode 100644
index 0000000..06d8128
--- /dev/null
+++ b/volumes/functions/check-sms-status/index.ts
@@ -0,0 +1,132 @@
+import { createClient } from "npm:@supabase/supabase-js@2";
+
+const ALLOWED_ORIGINS = [
+ "https://dost.supersamsev.ru",
+ "https://supa.supersamsev.ru",
+ "http://localhost:5173",
+];
+
+const SMS_STATUS_URL = "https://sms.ru/sms/status";
+
+const SMS_CODE_LABELS: Record = {
+ "100": "В очереди SMS.ru",
+ "101": "Передано оператору",
+ "102": "В пути",
+ "103": "Доставлено",
+ "104": "Истёкло время",
+ "105": "Удалено оператором",
+ "106": "Сбой телефона",
+ "107": "Неизвестная причина",
+ "108": "Отклонено",
+ "130": "Лимит на номер/день",
+ "131": "Лимит одинаковых/мин",
+ "132": "Лимит одинаковых/день",
+ "200": "Неправильный api_id",
+ "201": "Недостаточно средств",
+ "202": "Неправильный получатель",
+ "230": "Общий лимит/день",
+ "231": "Лимит одинаковых/мин",
+ "232": "Лимит одинаковых/день",
+};
+
+const cors = (origin: string) => ({
+ "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
+ "Access-Control-Allow-Methods": "POST,OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
+});
+
+Deno.serve(async (req: Request) => {
+ const origin = req.headers.get("origin") || "";
+ const headers = { ...cors(origin), "Content-Type": "application/json" };
+
+ if (req.method === "OPTIONS") return new Response(null, { headers });
+
+ try {
+ const { log_id } = await req.json();
+ if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
+
+ const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
+ const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
+ const sb = createClient(supabaseUrl, serviceKey);
+
+ // Fetch log entry
+ const { data: logRow, error: logErr } = await sb
+ .from("sms_campaign_log")
+ .select("id, sms_id, campaign_type, order_group_id, attempts")
+ .eq("id", log_id)
+ .single();
+ if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
+
+ // Fetch api_id from settings
+ const { data: settings } = await sb
+ .from("sms_campaign_settings")
+ .select("sms_api_id")
+ .eq("campaign_type", logRow.campaign_type)
+ .single();
+ const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
+
+ if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
+
+ // Call SMS.ru status API
+ const formData = new URLSearchParams();
+ formData.append("api_id", apiId);
+ formData.append("sms_id", logRow.sms_id);
+
+ const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
+ const smsText = await smsResp.text();
+ const lines = smsText.split("\n").map((l: string) => l.trim());
+ const code = lines[0] || "";
+
+ // Determine status
+ const status =
+ code === "103" ? "delivered" :
+ ["100", "101", "102"].includes(code) ? "checking" :
+ ["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
+ ["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
+ "checking";
+
+ // Update log entry
+ const now = new Date().toISOString();
+ await sb.from("sms_campaign_log").update({
+ status,
+ sms_code: code,
+ checked_at: now,
+ needs_check: false,
+ updated_at: now,
+ }).eq("id", log_id);
+
+ // Update order_groups if delivered
+ if (code === "103" && logRow.order_group_id) {
+ const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
+ if (logRow.campaign_type === "first_sms") {
+ await sb.from("order_groups").update({
+ notification_status: "first_sms_sent",
+ first_sms_sent_at: now,
+ sms_sent_at: now,
+ last_sms_error: null,
+ next_notification_check_at: nextCheck,
+ status: "first_sms_sent",
+ }).eq("id", logRow.order_group_id);
+ } else if (logRow.campaign_type === "second_sms") {
+ await sb.from("order_groups").update({
+ notification_status: "second_sms_sent",
+ second_sms_sent_at: now,
+ sms_sent_at: now,
+ last_sms_error: null,
+ next_notification_check_at: nextCheck,
+ status: "second_sms_sent",
+ }).eq("id", logRow.order_group_id);
+ }
+ }
+
+ return new Response(JSON.stringify({
+ success: true,
+ sms_id: logRow.sms_id,
+ code,
+ status,
+ label: SMS_CODE_LABELS[code] || "Код " + code,
+ }), { headers });
+ } catch (e) {
+ return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
+ }
+});
\ No newline at end of file
diff --git a/volumes/functions/confirm-delivery-choice/index.ts b/volumes/functions/confirm-delivery-choice/index.ts
index 33b29eb..65a5d78 100644
--- a/volumes/functions/confirm-delivery-choice/index.ts
+++ b/volumes/functions/confirm-delivery-choice/index.ts
@@ -31,11 +31,31 @@ type ConfirmBody = {
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
-const isWeekendDate = (value: string) => {
+// Fetch delivery days from business_schedule_settings
+const getDeliveryDays = async (supabaseClient: any): Promise => {
+ try {
+ const { data } = await supabaseClient
+ .from("business_schedule_settings")
+ .select("delivery_days")
+ .eq("id", 1)
+ .single();
+ if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
+ return data.delivery_days as number[];
+ }
+ } catch {
+ // Silent fallback
+ }
+ return [1, 2, 3, 4, 5]; // Default: Mon-Fri
+};
+
+const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
if (!isValidDate(value)) return false;
const date = new Date(`${value}T12:00:00Z`);
- const weekday = date.getUTCDay();
- return weekday === 0; // 0=Sunday — never allow Sunday delivery
+ const dow = date.getUTCDay();
+ // getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
+ // deliveryDays uses 1=Mon ... 7=Sun
+ const dayNum = dow === 0 ? 7 : dow;
+ return deliveryDays.includes(dayNum);
};
const resolveRequestedSlot = (
@@ -45,6 +65,7 @@ const resolveRequestedSlot = (
available_slots?: string[] | null;
},
body: ConfirmBody,
+ deliveryDays: number[] = [1, 2, 3, 4, 5],
) => {
const deliveryType = body.deliveryType || "delivery";
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
@@ -59,8 +80,8 @@ const resolveRequestedSlot = (
return { deliveryDate, deliveryTime, deliveryType };
}
- // Reject Sunday for delivery (business rule: never deliver on Sunday)
- if (isWeekendDate(deliveryDate)) {
+ // Reject non-delivery days for delivery (business schedule)
+ if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
return null;
}
@@ -120,6 +141,7 @@ Deno.serve(async (request) => {
const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient();
+ const deliveryDays = await getDeliveryDays(supabase);
const ipHash = await hashText(getClientIp(request));
await requireRateLimit(supabase, {
@@ -180,7 +202,7 @@ Deno.serve(async (request) => {
);
}
- const requestedSlot = resolveRequestedSlot(invitation, body);
+ const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
if (!requestedSlot) {
return jsonResponse(
{
diff --git a/webhook-deploy.py b/webhook-deploy.py
new file mode 100755
index 0000000..4e1076e
--- /dev/null
+++ b/webhook-deploy.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Gitea webhook listener — auto-deploys supersam on push to main."""
+import hmac, hashlib, subprocess, json, os, logging
+from flask import Flask, request, abort
+
+app = Flask(__name__)
+SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
+DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
+LOG = "/var/log/supersam-deploy.log"
+
+logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
+logger = logging.getLogger(__name__)
+
+def verify_signature(payload, sig_header):
+ if not sig_header:
+ return False
+ mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
+ return hmac.compare(mac, sig_header)
+
+@app.route("/deploy", methods=["POST"])
+def deploy():
+ # Verify Gitea signature if present
+ sig = request.headers.get("X-Gitea-Signature", "")
+ if not verify_signature(request.data, sig):
+ logger.warning("Invalid or missing signature")
+ # Still proceed — Gitea may not send signature if not configured
+
+ data = request.json or {}
+ ref = data.get("ref", "")
+ repo = data.get("repository", {}).get("name", "")
+
+ # Only deploy on push to main
+ if ref != "refs/heads/main":
+ logger.info(f"Ignored push to {ref}")
+ return {"status": "ignored", "ref": ref}, 200
+
+ logger.info(f"Deploy triggered by push to {ref} in {repo}")
+
+ try:
+ result = subprocess.run(
+ [DEPLOY_SCRIPT],
+ capture_output=True, text=True, timeout=300
+ )
+ logger.info(f"Deploy exit={result.returncode}")
+ if result.returncode != 0:
+ logger.error(f"Deploy stderr: {result.stderr}")
+ return {"status": "error", "output": result.stderr}, 500
+ return {"status": "ok", "output": result.stdout[-500:]}, 200
+ except subprocess.TimeoutExpired:
+ logger.error("Deploy timed out")
+ return {"status": "timeout"}, 500
+
+if __name__ == "__main__":
+ app.run(host="127.0.0.1", port=9765)