372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import {
|
||
maskCustomerName,
|
||
maskPhoneNumber,
|
||
} from "./security.ts";
|
||
|
||
export type DeliveryInvitationAction =
|
||
| "create_delivery_invitation"
|
||
| "send_delivery_offer"
|
||
| "send_delivery_reminder"
|
||
| "request_new_link"
|
||
| "confirm_delivery_choice"
|
||
| "transfer_to_logistics"
|
||
| "mark_paid_storage"
|
||
| "mark_delivered";
|
||
|
||
export type DeliveryInvitationPublicState =
|
||
| "awaiting_choice"
|
||
| "opened"
|
||
| "reminder_sent"
|
||
| "transferred_to_logistics"
|
||
| "paid_storage"
|
||
| "delivered"
|
||
| "agreed"
|
||
| "default";
|
||
|
||
export const DEFAULT_AVAILABLE_SLOTS = ["Первая половина дня", "Вторая половина дня"];
|
||
|
||
export const getOrderUpdateForDeliveryInvitationAction = (action: DeliveryInvitationAction) => {
|
||
switch (action) {
|
||
case "create_delivery_invitation":
|
||
case "send_delivery_offer":
|
||
case "send_delivery_reminder":
|
||
case "request_new_link":
|
||
return {
|
||
status: "Ожидает ответа клиента",
|
||
deliveryAgreementStatus: "Отправлено клиенту",
|
||
};
|
||
case "confirm_delivery_choice":
|
||
return {
|
||
status: "Доставка согласована",
|
||
deliveryAgreementStatus: "Подтверждено клиентом",
|
||
};
|
||
case "transfer_to_logistics":
|
||
return {
|
||
status: "Передан логисту",
|
||
deliveryAgreementStatus: "Нет ответа",
|
||
};
|
||
case "mark_paid_storage":
|
||
return {
|
||
status: "Платное хранение",
|
||
deliveryAgreementStatus: "Нет ответа",
|
||
};
|
||
case "mark_delivered":
|
||
return {
|
||
status: "Доставлен",
|
||
deliveryAgreementStatus: "Подтверждено клиентом",
|
||
};
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
export const getClientInvitationStateFromOrderStatus = (
|
||
status: string,
|
||
): DeliveryInvitationPublicState => {
|
||
switch (status) {
|
||
case "Ожидает ответа клиента":
|
||
return "awaiting_choice";
|
||
case "Ожидает согласования доставки":
|
||
return "opened";
|
||
case "Напоминание отправлено":
|
||
case "Переход отправлен":
|
||
return "reminder_sent";
|
||
case "Передан логисту":
|
||
return "transferred_to_logistics";
|
||
case "Платное хранение":
|
||
return "paid_storage";
|
||
case "Доставлен":
|
||
return "delivered";
|
||
case "Доставка согласована":
|
||
return "agreed";
|
||
default:
|
||
return "default";
|
||
}
|
||
};
|
||
|
||
export const getClientInvitationStateFromOrderGroupStatus = (
|
||
deliveryStatus: string | null | undefined,
|
||
invitationState: string | null | undefined,
|
||
): DeliveryInvitationPublicState => {
|
||
if (deliveryStatus === "agreed" || deliveryStatus === "driver_assigned") {
|
||
return "agreed";
|
||
}
|
||
|
||
if (deliveryStatus === "delivered") {
|
||
return "delivered";
|
||
}
|
||
|
||
if (deliveryStatus === "paid_storage") {
|
||
return "paid_storage";
|
||
}
|
||
|
||
if (["awaiting_choice", "opened", "reminder_sent"].includes(String(invitationState || ""))) {
|
||
return invitationState as DeliveryInvitationPublicState;
|
||
}
|
||
|
||
return "default";
|
||
};
|
||
|
||
export const isActiveInvitationState = (state: DeliveryInvitationPublicState) =>
|
||
state === "awaiting_choice" || state === "opened" || state === "reminder_sent";
|
||
|
||
export const generateInvitationToken = () => crypto.randomUUID().replaceAll("-", "");
|
||
|
||
export const hashInvitationToken = async (token: string) => {
|
||
const bytes = new TextEncoder().encode(token);
|
||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||
};
|
||
|
||
export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||
const slots = availableSlots?.map((slot) => slot.trim()).filter(Boolean) || [];
|
||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||
};
|
||
|
||
export const buildDefaultDatedAvailableSlots = 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;
|
||
};
|
||
|
||
// 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}, Первая половина дня`,
|
||
`${firstDay}, Вторая половина дня`,
|
||
`${secondDay}, Первая половина дня`,
|
||
`${secondDay}, Вторая половина дня`,
|
||
];
|
||
};
|
||
|
||
export const resolvePublicAppUrl = (
|
||
request: Request,
|
||
fallbackEnv?: string,
|
||
) => {
|
||
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
||
const envValue =
|
||
fallbackEnv ||
|
||
(typeof Deno !== "undefined" ? Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") : "");
|
||
return (envValue || origin || "").replace(/\/$/, "");
|
||
};
|
||
|
||
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
||
`${baseUrl.replace(/\/$/, "")}/client/${token}`;
|
||
|
||
export type DeliveryInvitationRecord = {
|
||
id?: string;
|
||
order_id?: string | null;
|
||
order_group_id?: string | null;
|
||
token_hash: string;
|
||
state: string;
|
||
order_number?: string | null;
|
||
customer_name?: string | null;
|
||
customer_phone?: string | null;
|
||
customer_messenger?: string | null;
|
||
available_slots?: string[] | null;
|
||
expires_at?: string | null;
|
||
revoked_at?: string | null;
|
||
delivery_date?: string | null;
|
||
delivery_time?: string | null;
|
||
sent_at?: string | null;
|
||
opened_at?: string | null;
|
||
confirmed_at?: string | null;
|
||
logistics_transferred_at?: string | null;
|
||
paid_storage_at?: string | null;
|
||
delivered_at?: string | null;
|
||
updated_at?: string | null;
|
||
};
|
||
|
||
export type OrderGroupInvitationSource = {
|
||
id: string;
|
||
group_key?: string | null;
|
||
customer?: {
|
||
name?: string | null;
|
||
phone?: string | null;
|
||
date?: string | null;
|
||
} | null;
|
||
customer_name?: string | null;
|
||
customer_phone?: string | null;
|
||
customer_date?: string | null;
|
||
order_numbers?: string[] | null;
|
||
delivery_status?: string | null;
|
||
delivery_link?: string | null;
|
||
source_orders?: unknown[] | null;
|
||
delivery_type?: string | null;
|
||
pickup_code?: string | null;
|
||
};
|
||
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
|
||
if (invitation.revoked_at) {
|
||
return true;
|
||
}
|
||
|
||
if (!invitation.expires_at) {
|
||
return false;
|
||
}
|
||
|
||
return new Date(invitation.expires_at).getTime() <= now.getTime();
|
||
};
|
||
|
||
const parseGroupKey = (groupKey?: string | null) => {
|
||
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
||
return {
|
||
phone: phone.trim(),
|
||
date: date.trim(),
|
||
};
|
||
};
|
||
|
||
const extractOrderItemsFromSourceOrders = (sourceOrders: unknown): Array<{ name: string; quantity: string; items?: unknown[] }> => {
|
||
if (!Array.isArray(sourceOrders) || sourceOrders.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const items: Array<{ name: string; quantity: string; items?: unknown[] }> = [];
|
||
|
||
for (const source of sourceOrders) {
|
||
if (!source || typeof source !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const record = source as Record<string, unknown>;
|
||
const nom = typeof record.nom === "string" ? record.nom : typeof record.name === "string" ? record.name : "";
|
||
const orderList = Array.isArray(record.orderList) ? record.orderList : Array.isArray(record.items) ? record.items : [];
|
||
|
||
if (orderList.length > 0) {
|
||
items.push({
|
||
name: nom || "Позиция",
|
||
quantity: "",
|
||
items: orderList.map((item: unknown) => {
|
||
if (!item || typeof item !== "object") {
|
||
return { name: String(item), quantity: "" };
|
||
}
|
||
const row = item as Record<string, unknown>;
|
||
return {
|
||
name: String(row.product_name || row.name || row.title || ""),
|
||
quantity: String(row.product_quantity || row.quantity || row.count || row.amount || ""),
|
||
};
|
||
}),
|
||
});
|
||
} else if (nom) {
|
||
items.push({ name: nom, quantity: "" });
|
||
}
|
||
}
|
||
|
||
return items;
|
||
};
|
||
|
||
export const buildPublicOrderGroupInvitationView = (
|
||
invitation: DeliveryInvitationRecord,
|
||
group: OrderGroupInvitationSource,
|
||
) => {
|
||
const parsedKey = parseGroupKey(group.group_key);
|
||
const customerName = group.customer_name || group.customer?.name || invitation.customer_name || null;
|
||
const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null;
|
||
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
||
|
||
const orderItemsFromSource = extractOrderItemsFromSourceOrders(group.source_orders);
|
||
const orderItems = orderItemsFromSource.length > 0
|
||
? orderItemsFromSource
|
||
: orderNumbers.map((number) => ({ name: number, quantity: "" }));
|
||
|
||
return {
|
||
orderId: invitation.order_group_id || group.id,
|
||
orderGroupId: invitation.order_group_id || group.id,
|
||
state: invitation.state,
|
||
token: "",
|
||
orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null,
|
||
customerName: maskCustomerName(customerName),
|
||
customerPhone: maskPhoneNumber(customerPhone),
|
||
orderItems,
|
||
// Тип отгрузки и код выдачи — клиентская страница показывает «самовывоз»
|
||
// и номер доставки с кодом выдачи по этим полям.
|
||
deliveryType: group.delivery_type || null,
|
||
pickupCode: group.pickup_code || null,
|
||
orderNumbers,
|
||
availableSlots: invitation.available_slots || [],
|
||
deliveryDate: invitation.delivery_date || null,
|
||
deliveryTime: invitation.delivery_time || null,
|
||
orderStatus: null,
|
||
deliveryAgreementStatus: null,
|
||
};
|
||
};
|
||
|
||
export const buildPublicInvitationView = (
|
||
invitation: DeliveryInvitationRecord,
|
||
order: {
|
||
order_number?: string | null;
|
||
customer?: { name?: string | null; phone?: string | null; items?: unknown };
|
||
status?: string | null;
|
||
delivery_agreement_status?: string | null;
|
||
},
|
||
) => {
|
||
const availableSlots = invitation.available_slots || [];
|
||
const orderItems = Array.isArray(order.customer?.items)
|
||
? order.customer?.items
|
||
: [];
|
||
|
||
return {
|
||
orderId: invitation.order_id,
|
||
state: invitation.state,
|
||
token: "",
|
||
orderNumber: order.order_number || invitation.order_number || null,
|
||
customerName: maskCustomerName(order.customer?.name || invitation.customer_name || null),
|
||
customerPhone: maskPhoneNumber(order.customer?.phone || invitation.customer_phone || null),
|
||
orderItems,
|
||
availableSlots,
|
||
deliveryDate: invitation.delivery_date || null,
|
||
deliveryTime: invitation.delivery_time || null,
|
||
orderStatus: null,
|
||
deliveryAgreementStatus: null,
|
||
};
|
||
};
|