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

270 lines
8.5 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") {
return "agreed";
}
if (deliveryStatus === "delivered") {
return "delivered";
}
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 = (now = new Date()) => {
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
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));
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(/\/$/, "")}/delivery/${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;
};
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(),
};
};
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 : [];
return {
orderId: invitation.order_group_id || group.id,
orderGroupId: invitation.order_group_id || group.id,
state: invitation.state,
token: "",
orderNumber: invitation.order_number || group.group_key || orderNumbers[0] || null,
customerName: maskCustomerName(customerName),
customerPhone: maskPhoneNumber(customerPhone),
orderItems: orderNumbers.map((number) => ({ name: number, quantity: "" })),
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,
};
};