diff --git a/.env.example b/.env.example index 4623955..aef99c3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +VITE_ENABLE_DEMO=false VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key APP_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/.gitignore b/.gitignore index 5e3a82f..c2ad370 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ node_modules dist .env -.env.local +.env.* +!.env.example .DS_Store .worktrees .superpowers +.ruff_cache diff --git a/1 b/1 new file mode 100644 index 0000000..0083c04 --- /dev/null +++ b/1 @@ -0,0 +1,24 @@ +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the group registry only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the delivery registry only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + +stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only +Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes. + at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3) + diff --git a/Caddyfile b/Caddyfile index 90321ea..8c1463e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -3,4 +3,14 @@ root * /usr/share/caddy file_server +header { + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-XSS-Protection "0" + Cross-Origin-Opener-Policy "same-origin" +} + try_files {path} /index.html diff --git a/Dockerfile b/Dockerfile index 3b71083..9eee5ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,3 +11,4 @@ FROM caddy:2-alpine COPY --from=build /app/dist /usr/share/caddy COPY Caddyfile /etc/caddy/Caddyfile EXPOSE 80 +USER 1000:1000 diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 545ba61..a477ee3 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -15,6 +15,17 @@ services: - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.service=supersam-app - traefik.http.services.supersam-app.loadbalancer.server.port=80 + # Redirect HTTP to HTTPS + - traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`) + - traefik.http.routers.supersam-app-http.entryPoints=http + - traefik.http.routers.supersam-app-http.middlewares=redirect-to-https + - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https + - traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true + # Security headers via Traefik + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY + - traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin + - traefik.http.routers.supersam-app.middlewares=supersam-sec networks: coolify: diff --git a/docs/sql/public-delivery-choice-rpc.sql b/docs/sql/public-delivery-choice-rpc.sql index 87ab1fe..5694c9b 100644 --- a/docs/sql/public-delivery-choice-rpc.sql +++ b/docs/sql/public-delivery-choice-rpc.sql @@ -77,12 +77,11 @@ begin ); v_customer_name := coalesce( nullif(v_group.customer_name, ''), - nullif(v_group.customer ->> 'name', ''), nullif(v_invitation.customer_name, '') ); v_customer_phone := coalesce( nullif(v_group.customer_phone, ''), - nullif(v_group.customer ->> 'phone', ''), + nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '') ); select coalesce( diff --git a/index.html b/index.html index 62be73b..91bbccc 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,13 @@
- Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня. + Показываем только согласованные к доставке группы. Выберите дату ниже.
-Нет групп
-Дата доставки
-{formatDeliveryDateDisplay(order.deliveryDate)}
+{formatDeliveryDateDisplay(order.deliveryDate)}
Время доставки
-{renderValue(order.deliveryTime || order.deliveryHalfDay)}
+{renderValue(order.deliveryTime || order.deliveryHalfDay)}
++ Водитель +
+{order.assignedDriverId ? renderValue(order.assignedDriverName) : "Не назначен"}
++ Телефон +
+ + {renderValue(order.customerPhone)} + ++ Адрес доставки +
+{renderValue(order.deliveryAddress)}
Группа
-{renderValue(order.groupKey)}
+Номер счёта
+{renderValue(order.orderNumberSummary)}
Клиент
-{renderValue(order.customerName)}
+{renderValue(order.customerName)}
Телефон
-{renderValue(order.customerPhone)}
-Дата
-{renderValue(order.customerDate)}
-Адрес доставки
-{renderValue(order.deliveryAddress)}
+Дата счёта
+{renderValue(order.customerDate)}
Всего заказов
-{order.ordersCount ?? 0}
+{order.ordersCount ?? 0}
Готово
-{order.readyCount ?? 0}
+{order.readyCount ?? 0}
Не готово
-{order.notReadyCount ?? 0}
+{order.notReadyCount ?? 0}
Обновлена
-{formatDateTime(order.updatedAt)}
+{formatDateTime(order.updatedAt)}
Статус доставки
-{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}
+{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}
+ {order.assignedDriverId + ? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.` + : "Выберите водителя для доставки."} +
+{driverMessage}
+ ) : null} ++ Обновите статус по мере выполнения доставки. +
+1-е SMS отправлено
+{formatDateTime(order.firstSmsSentAt)}
+2-е SMS отправлено
+{formatDateTime(order.secondSmsSentAt)}
+SMS отправлено
+Нет
+SMS отправлено
-{order.smsSentAt ? "Да" : "Нет"}
+Ручное согласование выполнено
+{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}
{formatDateTime(order.createdFromExchangeAt)}
Ключ источника
-{order.sourceKey}
-
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js
index 7c323ec..e00acb4 100644
--- a/src/services/orderGroupViews.js
+++ b/src/services/orderGroupViews.js
@@ -4,6 +4,7 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus
export const DELIVERY_GROUP_STATUS_LABELS = {
pending_confirmation: "Ожидает согласования",
+ manual_confirmation_required: "Требуется ручное подтверждение",
agreed: "Согласовано",
driver_assigned: "Назначен водитель",
loaded: "Загружено",
@@ -13,6 +14,16 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
cancelled: "Отменено",
};
+export const NOTIFICATION_STATUS_LABELS = {
+ not_started: "",
+ link_ready: "Ссылка готова",
+ first_sms_sent: "1-е приглашение отправлено",
+ second_sms_sent: "2-е приглашение отправлено",
+ send_failed: "Ошибка отправки",
+ confirmed: "Согласовано",
+ manual_required: "Переведено в ручное",
+};
+
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"agreed",
"driver_assigned",
@@ -46,7 +57,7 @@ const normalizeDeliveryHalfDayLabel = (value) => {
return HALF_DAY_LABELS.afternoon;
}
- return normalized;
+ return "";
};
const parseJsonIfNeeded = (value) => {
@@ -133,13 +144,19 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
}
+ const notificationStatus = group?.notificationStatus || group?.notification_status;
+ const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus];
+ if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") {
+ return notificationLabel;
+ }
+
return getOrderGroupStatusLabel(group?.status);
};
export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
- if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
+ if (deliveryStatus) {
return `delivery:${deliveryStatus}`;
}
@@ -269,11 +286,13 @@ export const ORDER_GROUP_STATUS_LABELS = {
ready_for_notification: "Готово к уведомлению",
sms_sent: "SMS отправлены",
manual_work: "Нужна ручная работа",
+ ready_to_launch: "Готово к запуску",
};
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "all", label: "Все статусы" },
- { value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification },
+ { value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation },
+ { value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required },
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
@@ -281,31 +300,34 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
- { value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent },
- { value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work },
];
export const getOrderGroupStatusLabel = (status) =>
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
export const getOrderGroupDeliveryStatusTone = (status) => {
- if (status === "agreed") {
- return "accent";
+ switch (status) {
+ case "pending_confirmation":
+ return "neutral";
+ case "manual_confirmation_required":
+ return "warning";
+ case "agreed":
+ return "accent";
+ case "driver_assigned":
+ return "info";
+ case "loaded":
+ return "info";
+ case "on_route":
+ return "accent";
+ case "delivered":
+ return "accent";
+ case "problem":
+ return "danger";
+ case "cancelled":
+ return "danger";
+ default:
+ return "neutral";
}
-
- if (status === "problem") {
- return "warning";
- }
-
- if (status === "delivered") {
- return "accent";
- }
-
- if (status === "cancelled") {
- return "danger";
- }
-
- return "neutral";
};
export const groupOrderGroupsByDate = (groups) => {
@@ -343,6 +365,12 @@ export const groupOrderGroupsByDate = (groups) => {
};
const getBucketKey = (group) => {
+ const notificationStatus = group?.notificationStatus || group?.notification_status;
+
+ if (notificationStatus === "manual_required") {
+ return "manual_work";
+ }
+
if (group.smsSentAt) {
return "sms_sent";
}
@@ -397,6 +425,14 @@ export const getOrderGroupStatusTone = (group) => {
return getOrderGroupDeliveryStatusTone(deliveryStatus);
}
+ const notificationStatus = group?.notificationStatus || group?.notification_status;
+ if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
+ return "warning";
+ }
+ if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
+ return "accent";
+ }
+
if (group.smsSentAt) {
return "accent";
}
diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js
index 2a26538..90be240 100644
--- a/src/services/supabase/orderGroupRepository.js
+++ b/src/services/supabase/orderGroupRepository.js
@@ -1,4 +1,5 @@
import { safeSupabaseCall } from "../safeSupabaseCall";
+import logger from "../../utils/logger";
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import {
getOrderGroupDeliveryHalfDay,
@@ -77,7 +78,16 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
? rawDeliveryHalfDay
: "";
- const deliveryAddress = normalizeText(row.delivery_address);
+
+ const extractAddressFromSourceOrders = (sourceOrders) => {
+ if (!Array.isArray(sourceOrders) || !sourceOrders.length) {
+ return "";
+ }
+ const first = sourceOrders[0];
+ return normalizeText(first.adress || first.address || "");
+ };
+
+ const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders);
return {
id: row.id,
@@ -96,12 +106,18 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate,
deliveryAddress,
+ assignedDriverId: row.assigned_driver_id || null,
+ assignedDriverName: row.assigned_driver?.name || "",
ordersCount,
readyCount,
notReadyCount,
orderNumbers,
status: row.status || "draft",
smsSentAt: row.sms_sent_at || null,
+ firstSmsSentAt: row.first_sms_sent_at || null,
+ secondSmsSentAt: row.second_sms_sent_at || null,
+ manualConfirmationAt: row.manual_confirmation_at || null,
+ notificationStatus: normalizeText(row.notification_status),
createdFromExchangeAt: row.created_from_exchange_at || null,
sourceKey: row.source_key || null,
legacyCustomerName: row.legacy_customer_name || null,
@@ -148,6 +164,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
sourceOrders: row.source_orders,
}),
getOrderGroupDeliveryStatusLabel(deliveryStatus),
+ row.notification_status,
+ extractAddressFromSourceOrders(row.source_orders),
]
.filter(Boolean)
.join(" ")
@@ -162,7 +180,7 @@ export const updateOrderGroupDeliveryChoice = async ({
}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
- const { data, error } = await client
+ const updateResult = await client
.from("order_groups")
.update({
delivery_status: "agreed",
@@ -171,8 +189,16 @@ export const updateOrderGroupDeliveryChoice = async ({
notification_status: "confirmed",
updated_at: new Date().toISOString(),
})
+ .eq("id", orderGroupId);
+
+ if (updateResult.error) {
+ throw updateResult.error;
+ }
+
+ const { data, error } = await client
+ .from("order_groups")
+ .select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.eq("id", orderGroupId)
- .select("*")
.single();
if (error) {
@@ -183,18 +209,97 @@ export const updateOrderGroupDeliveryChoice = async ({
}, "Ошибка сохранения согласования доставки");
};
+export const assignDriverToOrderGroup = async ({
+ orderGroupId,
+ driverId,
+}) => {
+ return safeSupabaseCall(async () => {
+ const client = requireSupabase();
+
+ logger.debug("[assignDriver] orderGroupId:", orderGroupId, "driverId:", driverId);
+
+ // Use RPC to bypass RLS on order_groups update
+ const { data: rpcData, error: rpcError } = await client.rpc("assign_driver", {
+ p_order_group_id: orderGroupId,
+ p_driver_id: driverId || null,
+ });
+
+ logger.debug("[assignDriver] rpc result:", { rpcData, rpcError });
+ if (rpcError) {
+ throw rpcError;
+ }
+
+ if (!rpcData) {
+ throw new Error("Группа не найдена");
+ }
+
+ // Fetch with driver join for the mapper
+ const { data, error } = await client
+ .from("order_groups")
+ .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
+ .eq("id", orderGroupId)
+ .single();
+
+ if (error) {
+ throw error;
+ }
+
+ return mapOrderGroupRowToDeliveryGroup(data);
+ }, "Ошибка назначения водителя");
+};
+
+export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
+ return safeSupabaseCall(async () => {
+ const client = requireSupabase();
+ const { data: rpcData, error: rpcError } = await client.rpc("update_delivery_status", {
+ p_order_group_id: orderGroupId,
+ p_status: status,
+ });
+
+ if (rpcError) {
+ throw rpcError;
+ }
+
+ // Fetch updated group
+ const { data, error } = await client
+ .from("order_groups")
+ .select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
+ .eq("id", orderGroupId)
+ .single();
+
+ if (error) {
+ throw error;
+ }
+
+ return mapOrderGroupRowToDeliveryGroup(data);
+ }, "Ошибка обновления статуса доставки");
+};
+
export const fetchOrderGroups = async () => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client
.from("order_groups")
- .select("*")
+ .select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.order("updated_at", { ascending: false });
if (error) {
throw error;
}
- return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
+ // Load driver names to patch groups where assigned_driver join is missing
+ const { data: drivers, error: driversError } = await client.rpc("get_drivers");
+ const driverMap = new Map();
+ if (!driversError && drivers) {
+ drivers.forEach((d) => driverMap.set(d.id, d.name || d.email));
+ }
+
+ return (data || []).map((row) => {
+ const group = mapOrderGroupRowToDeliveryGroup(row);
+ if (group && group.assignedDriverId && !group.assignedDriverName) {
+ group.assignedDriverName = driverMap.get(group.assignedDriverId) || "";
+ }
+ return group;
+ }).filter(Boolean);
}, "Ошибка загрузки групп доставки");
};
diff --git a/src/services/supabase/orderGroupRepository.test.js b/src/services/supabase/orderGroupRepository.test.js
index 2ded6f8..8da0bbd 100644
--- a/src/services/supabase/orderGroupRepository.test.js
+++ b/src/services/supabase/orderGroupRepository.test.js
@@ -116,10 +116,13 @@ describe("updateOrderGroupDeliveryChoice", () => {
selectMock.mockReset();
singleMock.mockReset();
- fromMock.mockReturnValue({ update: updateMock });
+ fromMock
+ .mockReturnValueOnce({ update: updateMock })
+ .mockReturnValueOnce({ select: selectMock });
updateMock.mockReturnValue({ eq: eqMock });
- eqMock.mockReturnValue({ select: selectMock });
- selectMock.mockReturnValue({ single: singleMock });
+ eqMock.mockReturnValueOnce({ error: null, status: 200, statusText: "OK" })
+ .mockReturnValueOnce({ single: singleMock });
+ selectMock.mockReturnValue({ eq: eqMock });
});
it("updates the group directly in order_groups", async () => {
@@ -163,7 +166,7 @@ describe("updateOrderGroupDeliveryChoice", () => {
updated_at: expect.any(String),
});
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
- expect(selectMock).toHaveBeenCalledWith("*");
+ expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)");
expect(singleMock).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/services/supabase/orderRepository.js b/src/services/supabase/orderRepository.js
index dc7fee3..d684c92 100644
--- a/src/services/supabase/orderRepository.js
+++ b/src/services/supabase/orderRepository.js
@@ -209,7 +209,7 @@ export const fetchOrders = async () => {
const client = requireSupabase();
const { data, error } = await client
.from("orders")
- .select("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)")
+ .select("id, order_number, customer, status, delivery_agreement_status, manager_id, logistician_id, assigned_driver_id, ready_for_delivery_at, delivery_flow_started_at, delivery_flow_source, source_order_number, source_order_date, source_customer_name, source_customer_phone, source_customer_email, source_customer_city, source_total_sum, source_paid_at, source_gateway, source_associated_bills_text, source_production_at, source_saw_at, source_glue_at, source_h_glue_at, source_curve_at, source_accept_at, source_ship_at, source_sms_legacy_at, source_payload, delivery_set_key, delivery_set_name, delivery_set_status, delivery_set_ready_at, delivery_ready_reason, created_at, updated_at, order_history(id, action, old_status, new_status, user_id, user_name, metadata, created_at), delivery_slots(id, delivery_date, delivery_time, logistician_id, logistician_name, status, created_at, selected_by_client_at), chat_messages(id, sender_type, sender_name, channel, text, external_message_id, payload, created_at), order_logisticians(order_id, logistician_id)")
.order("updated_at", { ascending: false });
if (error) {
diff --git a/src/services/supabase/userRepository.js b/src/services/supabase/userRepository.js
index 9231836..f2c49f9 100644
--- a/src/services/supabase/userRepository.js
+++ b/src/services/supabase/userRepository.js
@@ -1,4 +1,5 @@
import { safeSupabaseCall } from "../safeSupabaseCall";
+import logger from "../../utils/logger";
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
const requireSupabase = () => {
@@ -41,3 +42,28 @@ export const fetchUsers = async () => {
return (data || []).map(mapUserRowToAppUser).filter(Boolean);
}, "Ошибка загрузки пользователей");
};
+
+export const fetchDrivers = async () => {
+ return safeSupabaseCall(async () => {
+ const client = requireSupabase();
+ const { data, error } = await client.rpc("get_drivers");
+
+ logger.debug("[fetchDrivers] rpc raw:", { data, error });
+
+ if (error) {
+ throw error;
+ }
+
+ const mapped = (data || []).map((row) => ({
+ id: row.id,
+ email: row.email,
+ name: row.name,
+ role: "driver",
+ lastLogin: null,
+ botBindings: null,
+ }));
+
+ logger.debug("[fetchDrivers] mapped:", mapped);
+ return mapped;
+ }, "Ошибка загрузки водителей");
+};
diff --git a/src/utils/logger.js b/src/utils/logger.js
index 7fd0fd7..e5c28f8 100644
--- a/src/utils/logger.js
+++ b/src/utils/logger.js
@@ -1,7 +1,12 @@
+const isDev = typeof import.meta !== "undefined" ? import.meta.env.DEV : true;
+
const logger = {
info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""),
error: (message, error) => console.error(`[error] ${message}`, error ?? ""),
order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""),
+ debug: isDev
+ ? (message, payload) => console.debug(`[debug] ${message}`, payload ?? "")
+ : () => {},
};
export default logger;
diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest
new file mode 100644
index 0000000..3f309c0
--- /dev/null
+++ b/supabase/.temp/cli-latest
@@ -0,0 +1 @@
+v2.99.0
\ No newline at end of file
diff --git a/supabase/functions/_shared/chatbot.ts b/supabase/functions/_shared/chatbot.ts
index b3afc27..ba41bf3 100644
--- a/supabase/functions/_shared/chatbot.ts
+++ b/supabase/functions/_shared/chatbot.ts
@@ -1,4 +1,4 @@
-import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
+import { createClient } from "@supabase/supabase-js";
import { getOrderUpdateForInboundAction } from "./workflow.ts";
export type ProviderName = "telegram" | "vk" | "messenger_max";
@@ -19,6 +19,13 @@ export const createServiceClient = () => {
return createClient(supabaseUrl, serviceRoleKey);
};
+/** Create a Supabase client that respects RLS policies (uses anon key). */
+export const createAnonClient = () => {
+ const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
+ const anonKey = Deno.env.get("SUPABASE_ANON_KEY") || "";
+ return createClient(supabaseUrl, anonKey);
+};
+
export const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts
index 19af8f1..1b13906 100644
--- a/supabase/functions/_shared/security.ts
+++ b/supabase/functions/_shared/security.ts
@@ -104,12 +104,7 @@ const resolveAllowedOrigins = (mode: CorsMode) => {
return Array.from(new Set(configured));
}
- const currentMode = readEnv("NODE_ENV") || "development";
- if (currentMode === "production") {
- return [];
- }
-
- return [...DEFAULT_LOCAL_ORIGINS];
+ return [];
};
export class HttpError extends Error {
@@ -341,6 +336,40 @@ export const maskOrderNumber = (orderNumber: string | null | undefined) => {
return `…${value.slice(-4)}`;
};
+
+
+export const isValidUuid = (value: string): boolean => {
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
+};
+
+export const requireUuid = (value: string | undefined | null, label = "id"): string => {
+ const trimmed = (value || "").trim();
+ if (!trimmed || !isValidUuid(trimmed)) {
+ throw new HttpError(400, `Invalid ${label} format`);
+ }
+ return trimmed;
+};
+
+
+export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => {
+ const origin = request.headers.get("origin") || "";
+ const host = request.headers.get("host") || "";
+ if (!origin || !host) {
+ return false;
+ }
+ try {
+ const originHost = new URL(origin).host;
+ return allowedOrigins.some((allowed) => {
+ try {
+ return new URL(allowed).host === originHost;
+ } catch {
+ return allowed === origin;
+ }
+ });
+ } catch {
+ return false;
+ }
+};
export const requireRateLimit = async (
supabase: {
rpc: (
diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts
index 24254d3..5cfa666 100644
--- a/supabase/functions/confirm-delivery-choice/index.ts
+++ b/supabase/functions/confirm-delivery-choice/index.ts
@@ -4,6 +4,7 @@ import {
isActiveInvitationState,
isInvitationExpired,
} from "../_shared/delivery-invitations.ts";
+import { isValidUuid, requireUuid } from "../_shared/security.ts";
import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import {
@@ -14,6 +15,7 @@ import {
preflightResponse,
readJsonBody,
requireRateLimit,
+ requireSameOrigin,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
@@ -65,6 +67,19 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
+ const allowedOriginsForCsrf = ((): string[] => {
+ const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
+ const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
+ return [...envOrigins, appUrl].filter(Boolean);
+ })();
+
+ if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
+ const origin = request.headers.get("origin") || "";
+ if (origin) {
+ return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
+ }
+ }
+
try {
const { body } = await readJsonBody