chore: сохранить SQL аудита истории в репозитории + убрать мусор из индекса
- supabase/log_order_group_change.sql, supabase/order_history_rls.sql: триггер логирования изменений order_groups и его RLS существовали только на сервере (в БД работают с 2026-07-29), в git исходников не было — восстановить было нечем - scripts/__pycache__/*.pyc и DriverDeliveryPlanner.jsx.bak сняты с отслеживания (артефакты и бэкап, в .gitignore уже были __pycache__/ и *.bak — добавлен *.bak-*)
This commit is contained in:
parent
a1d1c3f7a2
commit
b69e5172d6
|
|
@ -11,3 +11,4 @@ volumes/db/data/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.bak
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,371 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
getOrderGroupDeliveryHalfDay,
|
|
||||||
getOrderGroupDeliveryStatusLabel,
|
|
||||||
getOrderGroupDeliveryStatusTone,
|
|
||||||
DRIVER_VISIBLE_DELIVERY_STATUSES,
|
|
||||||
isOrderGroupVisibleToDriver,
|
|
||||||
groupOrderGroupsByDate,
|
|
||||||
parseGroupDate,
|
|
||||||
} from "../../services/orderGroupViews";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Input } from "../UI/Input";
|
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
|
|
||||||
const extractCity = (address) => {
|
|
||||||
if (!address || typeof address !== "string") return null;
|
|
||||||
const trimmed = address.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
// Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc.
|
|
||||||
const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i);
|
|
||||||
if (cityMatch) {
|
|
||||||
return cityMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try common city names directly in the address
|
|
||||||
const knownCities = [
|
|
||||||
"Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория",
|
|
||||||
"Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман",
|
|
||||||
"Джанкой", "Красногвардейское", "Раздольное", "Черноморское",
|
|
||||||
];
|
|
||||||
for (const city of knownCities) {
|
|
||||||
if (trimmed.toLowerCase().includes(city.toLowerCase())) {
|
|
||||||
return city;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: first comma-separated segment if it looks like a city
|
|
||||||
const firstSegment = trimmed.split(/[,;]/)[0].trim();
|
|
||||||
if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) {
|
|
||||||
return firstSegment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeCity = (address) => {
|
|
||||||
const city = extractCity(address);
|
|
||||||
return city || "Севастополь";
|
|
||||||
};
|
|
||||||
|
|
||||||
const DRIVER_DELIVERY_STATUS_OPTIONS = [
|
|
||||||
{ value: "all", label: "Все статусы" },
|
|
||||||
...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({
|
|
||||||
value: status,
|
|
||||||
label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
|
|
||||||
const [filters, setFilters] = React.useState({
|
|
||||||
selectedDate: "",
|
|
||||||
deliveryStatus: "all",
|
|
||||||
selectedCity: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const driverOrderGroups = React.useMemo(
|
|
||||||
() => orderGroups.filter((group) => {
|
|
||||||
const isVisible = isOrderGroupVisibleToDriver(group);
|
|
||||||
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
|
|
||||||
return isVisible && isAssignedToMe;
|
|
||||||
}),
|
|
||||||
[orderGroups, currentUser],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build map of date -> count
|
|
||||||
const dateDeliveryMap = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
driverOrderGroups.forEach((group) => {
|
|
||||||
const date = group.deliveryDate;
|
|
||||||
if (date) {
|
|
||||||
map.set(date, (map.get(date) || 0) + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [driverOrderGroups]);
|
|
||||||
|
|
||||||
const sortedDeliveryDates = React.useMemo(() => {
|
|
||||||
return Array.from(dateDeliveryMap.keys()).sort();
|
|
||||||
}, [dateDeliveryMap]);
|
|
||||||
|
|
||||||
// Build map of city -> count
|
|
||||||
const cityDeliveryMap = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
driverOrderGroups.forEach((group) => {
|
|
||||||
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
|
|
||||||
map.set(city, (map.get(city) || 0) + 1);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [driverOrderGroups]);
|
|
||||||
|
|
||||||
const sortedCities = React.useMemo(() => {
|
|
||||||
return Array.from(cityDeliveryMap.keys()).sort((a, b) => {
|
|
||||||
// Севастополь first, then alphabetical
|
|
||||||
if (a === "Севастополь") return -1;
|
|
||||||
if (b === "Севастополь") return 1;
|
|
||||||
return a.localeCompare(b, "ru");
|
|
||||||
});
|
|
||||||
}, [cityDeliveryMap]);
|
|
||||||
|
|
||||||
const filteredOrderGroups = React.useMemo(() => {
|
|
||||||
let result = [...driverOrderGroups];
|
|
||||||
if (filters.selectedDate) {
|
|
||||||
result = result.filter((group) => group.deliveryDate === filters.selectedDate);
|
|
||||||
}
|
|
||||||
if (filters.deliveryStatus !== "all") {
|
|
||||||
result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus);
|
|
||||||
}
|
|
||||||
if (filters.selectedCity) {
|
|
||||||
result = result.filter((group) => {
|
|
||||||
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
|
|
||||||
return city === filters.selectedCity;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
|
|
||||||
|
|
||||||
const groupedOrderGroups = React.useMemo(
|
|
||||||
() => groupOrderGroupsByDate(filteredOrderGroups),
|
|
||||||
[filteredOrderGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deliveryCountLabel = `${filteredOrderGroups.length} ${
|
|
||||||
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const isDateSelected = (date) => filters.selectedDate === date;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Panel className="space-y-3 p-5">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
|
||||||
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Показываем только назначенные вам группы доставки. Выберите дату и город.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
Дата
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={filters.selectedDate}
|
|
||||||
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
Статус
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={filters.deliveryStatus}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date pills */}
|
|
||||||
{sortedDeliveryDates.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
|
|
||||||
className={[
|
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
|
||||||
!filters.selectedDate
|
|
||||||
? "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)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
Все даты
|
|
||||||
</button>
|
|
||||||
{sortedDeliveryDates.map((date) => {
|
|
||||||
const count = dateDeliveryMap.get(date) || 0;
|
|
||||||
const selected = isDateSelected(date);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={date}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
|
|
||||||
className={[
|
|
||||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
|
||||||
selected
|
|
||||||
? "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)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
|
|
||||||
{count > 0 && (
|
|
||||||
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* City pills */}
|
|
||||||
{sortedCities.length > 1 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
|
|
||||||
className={[
|
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
|
||||||
!filters.selectedCity
|
|
||||||
? "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)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
Все города
|
|
||||||
</button>
|
|
||||||
{sortedCities.map((city) => {
|
|
||||||
const count = cityDeliveryMap.get(city) || 0;
|
|
||||||
const selected = filters.selectedCity === city;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={city}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
|
|
||||||
className={[
|
|
||||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
|
||||||
selected
|
|
||||||
? "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)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span>{city}</span>
|
|
||||||
{count > 0 && (
|
|
||||||
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{groupedOrderGroups.length ? (
|
|
||||||
groupedOrderGroups.map((group) => {
|
|
||||||
// Group items by delivery status within each date
|
|
||||||
const statusBuckets = new Map();
|
|
||||||
for (const item of group.items) {
|
|
||||||
const s = item.deliveryStatus || item.delivery_status || "unknown";
|
|
||||||
const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s);
|
|
||||||
const tone = getOrderGroupDeliveryStatusTone(s);
|
|
||||||
if (!statusBuckets.has(s)) {
|
|
||||||
statusBuckets.set(s, { label, tone, items: [] });
|
|
||||||
}
|
|
||||||
statusBuckets.get(s).items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort status buckets in driver-relevant order
|
|
||||||
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"];
|
|
||||||
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
|
|
||||||
const ia = statusOrder.indexOf(a);
|
|
||||||
const ib = statusOrder.indexOf(b);
|
|
||||||
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
|
||||||
if (ia === -1) return 1;
|
|
||||||
if (ib === -1) return -1;
|
|
||||||
return ia - ib;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel key={group.date} className="space-y-4 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-semibold capitalize">
|
|
||||||
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
weekday: "long",
|
|
||||||
}) || "Без даты"}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone="neutral">
|
|
||||||
{(() => {
|
|
||||||
const d = parseGroupDate(group.date);
|
|
||||||
if (!d) return group.date || "—";
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const year = d.getFullYear();
|
|
||||||
return `${day}.${month}.${year}`;
|
|
||||||
})()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
|
|
||||||
<div key={statusValue} className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge tone={tone}>{label}</Badge>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {items.length === 1 ? "группа" : "группы"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
|
||||||
onClick={() => onOpenOrder?.(item.id)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-[var(--color-text)]">
|
|
||||||
{item.displayTitle || item.customerName || item.groupKey}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{item.customerDate} · {item.customerPhone}
|
|
||||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Panel className="p-6">
|
|
||||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Сейчас у вас нет назначенных групп доставки.
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_order_group_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_action text;
|
||||||
|
v_source text;
|
||||||
|
BEGIN
|
||||||
|
-- Determine who made the change
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
v_source := CASE WHEN v_user_id IS NOT NULL THEN 'user' ELSE 'system' END;
|
||||||
|
|
||||||
|
-- Track only meaningful field changes
|
||||||
|
IF NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'notification_status',
|
||||||
|
jsonb_build_object('old', OLD.notification_status, 'new', NEW.notification_status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'delivery_status',
|
||||||
|
jsonb_build_object('old', OLD.delivery_status, 'new', NEW.delivery_status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'status',
|
||||||
|
jsonb_build_object('old', OLD.status, 'new', NEW.status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'assigned_driver_id',
|
||||||
|
jsonb_build_object('old', OLD.assigned_driver_id, 'new', NEW.assigned_driver_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'delivery_link',
|
||||||
|
jsonb_build_object('old', OLD.delivery_link, 'new', NEW.delivery_link)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'first_sms_sent_at',
|
||||||
|
jsonb_build_object('old', OLD.first_sms_sent_at, 'new', NEW.first_sms_sent_at)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'second_sms_sent_at',
|
||||||
|
jsonb_build_object('old', OLD.second_sms_sent_at, 'new', NEW.second_sms_sent_at)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'last_sms_error',
|
||||||
|
jsonb_build_object('old', OLD.last_sms_error, 'new', NEW.last_sms_error)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Only insert if something meaningful changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determine action label
|
||||||
|
v_action := CASE
|
||||||
|
WHEN NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN 'delivery_status_change'
|
||||||
|
WHEN NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN 'notification_status_change'
|
||||||
|
WHEN NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN 'driver_assignment_change'
|
||||||
|
WHEN NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN 'delivery_link_change'
|
||||||
|
ELSE 'field_change'
|
||||||
|
END;
|
||||||
|
|
||||||
|
INSERT INTO order_history (order_group_id, action, old_status, new_status, user_id, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
v_action,
|
||||||
|
OLD.delivery_status,
|
||||||
|
NEW.delivery_status,
|
||||||
|
v_user_id,
|
||||||
|
jsonb_build_object('changes', v_changes, 'source', v_source, 'group_key', NEW.group_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Drop old trigger if exists
|
||||||
|
DROP TRIGGER IF EXISTS log_order_group_change_trigger ON public.order_groups;
|
||||||
|
|
||||||
|
-- Create trigger AFTER UPDATE (only when fields actually change)
|
||||||
|
CREATE TRIGGER log_order_group_change_trigger
|
||||||
|
AFTER UPDATE ON public.order_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (
|
||||||
|
NEW.notification_status IS DISTINCT FROM OLD.notification_status
|
||||||
|
OR NEW.delivery_status IS DISTINCT FROM OLD.delivery_status
|
||||||
|
OR NEW.status IS DISTINCT FROM OLD.status
|
||||||
|
OR NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id
|
||||||
|
OR NEW.delivery_link IS DISTINCT FROM OLD.delivery_link
|
||||||
|
OR NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at
|
||||||
|
OR NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at
|
||||||
|
OR NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error
|
||||||
|
)
|
||||||
|
EXECUTE FUNCTION public.log_order_group_change();
|
||||||
|
|
||||||
|
-- Grant access
|
||||||
|
GRANT SELECT ON order_history TO authenticated;
|
||||||
|
GRANT INSERT ON order_history TO authenticated;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Drop old policies that reference orders table
|
||||||
|
DROP POLICY IF EXISTS "history select by order role" ON order_history;
|
||||||
|
DROP POLICY IF EXISTS "history insert workflow" ON order_history;
|
||||||
|
|
||||||
|
-- New SELECT policy: users can read history for order_groups they have access to
|
||||||
|
CREATE POLICY "order_history_select" ON order_history
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM order_groups og
|
||||||
|
WHERE og.id = order_history.order_group_id
|
||||||
|
AND (
|
||||||
|
current_role_name() = ANY (ARRAY['admin', 'mega_admin', 'production_lead'])
|
||||||
|
OR (current_role_name() = 'manager')
|
||||||
|
OR (current_role_name() = 'logistician')
|
||||||
|
OR (current_role_name() = 'driver' AND og.assigned_driver_id = auth.uid())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT policy: only trigger (SECURITY DEFINER) inserts, users don't insert directly
|
||||||
|
CREATE POLICY "order_history_insert_trigger" ON order_history
|
||||||
|
FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
Loading…
Reference in New Issue