supersam/supabase/functions/_shared/delivery-invitations.ts

372 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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