409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import {
|
|
buildDefaultDatedAvailableSlots,
|
|
buildInvitationUrl,
|
|
generateInvitationToken,
|
|
getOrderUpdateForDeliveryInvitationAction,
|
|
hashInvitationToken,
|
|
normalizeAvailableSlots,
|
|
resolvePublicAppUrl,
|
|
} from "../_shared/delivery-invitations.ts";
|
|
import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts";
|
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
import {
|
|
getClientIp,
|
|
getCorsHeaders,
|
|
jsonResponse,
|
|
readJsonBody,
|
|
requireRateLimit,
|
|
verifyInternalRequest,
|
|
} from "../_shared/security.ts";
|
|
|
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
|
|
type CreateInvitationBody = {
|
|
orderId?: string;
|
|
orderGroupId?: string;
|
|
orderNumber?: string;
|
|
customerName?: string;
|
|
customerPhone?: string;
|
|
customerMessenger?: string;
|
|
availableSlots?: string[];
|
|
source?: string;
|
|
};
|
|
|
|
const parseGroupKey = (groupKey?: string | null) => {
|
|
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
|
return {
|
|
phone: phone.trim(),
|
|
date: date.trim(),
|
|
};
|
|
};
|
|
|
|
const resolveRequiredPublicAppUrl = (request: Request) => {
|
|
const publicBaseUrl = resolvePublicAppUrl(request);
|
|
if (!publicBaseUrl) {
|
|
throw new Error("PUBLIC_APP_URL is not configured");
|
|
}
|
|
|
|
return publicBaseUrl;
|
|
};
|
|
|
|
const createOrderGroupInvitation = async ({
|
|
body,
|
|
request,
|
|
corsHeaders,
|
|
}: {
|
|
body: CreateInvitationBody;
|
|
request: Request;
|
|
corsHeaders: HeadersInit;
|
|
}) => {
|
|
const supabase = createServiceClient();
|
|
const orderGroupId = String(body.orderGroupId || "").trim();
|
|
|
|
await requireRateLimit(supabase, {
|
|
scope: "delivery-invitation-create",
|
|
key: orderGroupId,
|
|
maxCount: 10,
|
|
windowSeconds: 600,
|
|
blockSeconds: 1800,
|
|
});
|
|
|
|
const { data: group, error: groupError } = await supabase
|
|
.from("order_groups")
|
|
.select("*")
|
|
.eq("id", orderGroupId)
|
|
.single();
|
|
|
|
if (groupError) {
|
|
throw groupError;
|
|
}
|
|
|
|
const parsedKey = parseGroupKey(group.group_key);
|
|
const customerName = body.customerName || group.customer_name || group.customer?.name || null;
|
|
const customerPhone = body.customerPhone || group.customer_phone || group.customer?.phone || parsedKey.phone || null;
|
|
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
|
const orderNumber = body.orderNumber || group.group_key || orderNumbers[0] || null;
|
|
|
|
if (!customerPhone) {
|
|
return jsonResponse({ ok: false, error: "customerPhone is required" }, 400, corsHeaders);
|
|
}
|
|
|
|
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
|
.from("delivery_invitations")
|
|
.select("id, state")
|
|
.eq("order_group_id", orderGroupId)
|
|
.in("state", ["awaiting_choice", "opened", "reminder_sent"])
|
|
.maybeSingle();
|
|
|
|
if (existingInvitationError) {
|
|
throw existingInvitationError;
|
|
}
|
|
|
|
if (existingInvitation) {
|
|
if (!group.delivery_link) {
|
|
const { error: revokeInvitationError } = await supabase
|
|
.from("delivery_invitations")
|
|
.update({
|
|
state: "default",
|
|
revoked_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", existingInvitation.id);
|
|
|
|
if (revokeInvitationError) {
|
|
throw revokeInvitationError;
|
|
}
|
|
} else {
|
|
return jsonResponse(
|
|
{
|
|
ok: true,
|
|
alreadyStarted: true,
|
|
invitation: {
|
|
id: existingInvitation.id,
|
|
orderGroupId,
|
|
state: existingInvitation.state,
|
|
url: group.delivery_link || null,
|
|
},
|
|
},
|
|
200,
|
|
corsHeaders,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (existingInvitation && !group.delivery_link) {
|
|
const { error: clearBrokenLinkError } = await supabase
|
|
.from("order_groups")
|
|
.update({
|
|
delivery_invitation_id: null,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", orderGroupId);
|
|
|
|
if (clearBrokenLinkError) {
|
|
throw clearBrokenLinkError;
|
|
}
|
|
}
|
|
|
|
const token = generateInvitationToken();
|
|
const tokenHash = await hashInvitationToken(token);
|
|
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
|
const url = buildInvitationUrl(publicBaseUrl, token);
|
|
const availableSlots = body.availableSlots?.length
|
|
? normalizeAvailableSlots(body.availableSlots)
|
|
: buildDefaultDatedAvailableSlots();
|
|
|
|
const invitationPayload = {
|
|
order_id: null,
|
|
order_group_id: orderGroupId,
|
|
token_hash: tokenHash,
|
|
state: "awaiting_choice",
|
|
order_number: orderNumber,
|
|
customer_name: customerName,
|
|
customer_phone: customerPhone,
|
|
customer_messenger: body.customerMessenger || null,
|
|
available_slots: availableSlots,
|
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
sent_at: null,
|
|
};
|
|
|
|
const { data: invitation, error: invitationError } = await supabase
|
|
.from("delivery_invitations")
|
|
.insert(invitationPayload)
|
|
.select("id")
|
|
.single();
|
|
|
|
if (invitationError) {
|
|
throw invitationError;
|
|
}
|
|
|
|
const { error: groupUpdateError } = await supabase
|
|
.from("order_groups")
|
|
.update({
|
|
delivery_invitation_id: invitation.id,
|
|
delivery_link: url,
|
|
notification_status: "link_ready",
|
|
next_notification_check_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", orderGroupId);
|
|
|
|
if (groupUpdateError) {
|
|
throw groupUpdateError;
|
|
}
|
|
|
|
await insertIntegrationEvent(supabase, {
|
|
order_id: null,
|
|
event_type: "delivery_invitation_created",
|
|
direction: "outbound",
|
|
status: "success",
|
|
payload: {
|
|
order_group_id: orderGroupId,
|
|
delivery_invitation_id: invitation.id,
|
|
token_hash: tokenHash,
|
|
available_slots: availableSlots,
|
|
},
|
|
});
|
|
|
|
return jsonResponse(
|
|
{
|
|
ok: true,
|
|
invitation: {
|
|
id: invitation.id,
|
|
orderGroupId,
|
|
token,
|
|
url,
|
|
state: "awaiting_choice",
|
|
availableSlots,
|
|
},
|
|
},
|
|
200,
|
|
corsHeaders,
|
|
);
|
|
};
|
|
|
|
Deno.serve(async (request) => {
|
|
if (request.method === "OPTIONS") {
|
|
const corsHeaders = getCorsHeaders(request, "integration");
|
|
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
|
}
|
|
|
|
if (request.method !== "POST") {
|
|
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
}
|
|
|
|
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
|
|
|
try {
|
|
const { body, rawBody } = await readJsonBody<CreateInvitationBody>(request, {
|
|
maxBytes: MAX_BODY_BYTES,
|
|
});
|
|
const auth = await verifyInternalRequest(request, rawBody, {
|
|
rawBody,
|
|
allowedClockSkewSeconds: 300,
|
|
});
|
|
|
|
if (!body.orderId && !body.orderGroupId) {
|
|
return jsonResponse({ error: "orderId or orderGroupId is required" }, 400, corsHeaders);
|
|
}
|
|
|
|
if (body.orderGroupId) {
|
|
return await createOrderGroupInvitation({ body, request, corsHeaders });
|
|
}
|
|
|
|
const orderId = body.orderId as string;
|
|
const supabase = createServiceClient();
|
|
await requireRateLimit(supabase, {
|
|
scope: "delivery-invitation-create",
|
|
key: orderId,
|
|
maxCount: 10,
|
|
windowSeconds: 600,
|
|
blockSeconds: 1800,
|
|
});
|
|
|
|
const token = generateInvitationToken();
|
|
const tokenHash = await hashInvitationToken(token);
|
|
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
|
|
|
|
const { data: currentOrder, error: orderError } = await supabase
|
|
.from("orders")
|
|
.select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
|
|
.eq("id", orderId)
|
|
.single();
|
|
|
|
if (orderError) {
|
|
throw orderError;
|
|
}
|
|
|
|
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
|
.from("delivery_invitations")
|
|
.select(
|
|
"id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at",
|
|
)
|
|
.eq("order_id", orderId)
|
|
.maybeSingle();
|
|
|
|
if (existingInvitationError) {
|
|
throw existingInvitationError;
|
|
}
|
|
|
|
if (currentOrder.delivery_flow_started_at || existingInvitation) {
|
|
return jsonResponse(
|
|
{
|
|
ok: true,
|
|
alreadyStarted: true,
|
|
invitation: existingInvitation
|
|
? {
|
|
orderId,
|
|
state: existingInvitation.state,
|
|
availableSlots: existingInvitation.available_slots || [],
|
|
orderNumber: existingInvitation.order_number || body.orderNumber || null,
|
|
customerName: existingInvitation.customer_name || body.customerName || null,
|
|
customerPhone: existingInvitation.customer_phone || body.customerPhone || null,
|
|
customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null,
|
|
}
|
|
: {
|
|
orderId,
|
|
state: "awaiting_choice",
|
|
},
|
|
},
|
|
200,
|
|
corsHeaders,
|
|
);
|
|
}
|
|
|
|
const invitationPayload = {
|
|
order_id: orderId,
|
|
token_hash: tokenHash,
|
|
state: "awaiting_choice",
|
|
order_number: body.orderNumber || null,
|
|
customer_name: body.customerName || null,
|
|
customer_phone: body.customerPhone || null,
|
|
customer_messenger: body.customerMessenger || null,
|
|
available_slots: normalizeAvailableSlots(body.availableSlots),
|
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
sent_at: new Date().toISOString(),
|
|
};
|
|
|
|
const { error: invitationError } = await supabase.from("delivery_invitations").insert(invitationPayload);
|
|
|
|
if (invitationError) {
|
|
throw invitationError;
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from("orders")
|
|
.update({
|
|
status: orderUpdate?.status,
|
|
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
ready_for_delivery_at: currentOrder.ready_for_delivery_at || new Date().toISOString(),
|
|
delivery_flow_started_at: new Date().toISOString(),
|
|
delivery_flow_source: body.source || "n8n",
|
|
})
|
|
.eq("id", orderId);
|
|
|
|
if (updateError) {
|
|
throw updateError;
|
|
}
|
|
|
|
const { error: historyError } = await supabase.from("order_history").insert({
|
|
order_id: orderId,
|
|
action: "Создание приглашения доставки",
|
|
old_status: currentOrder.status,
|
|
new_status: orderUpdate?.status,
|
|
metadata: {
|
|
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
|
channel: channelFromProvider("telegram"),
|
|
auth: auth.authenticatedBy,
|
|
},
|
|
});
|
|
|
|
if (historyError) {
|
|
throw historyError;
|
|
}
|
|
|
|
await insertIntegrationEvent(supabase, {
|
|
order_id: orderId,
|
|
event_type: "delivery_invitation_created",
|
|
direction: "outbound",
|
|
status: "success",
|
|
payload: {
|
|
token_hash: tokenHash,
|
|
available_slots: invitationPayload.available_slots,
|
|
},
|
|
});
|
|
|
|
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
|
|
|
return jsonResponse(
|
|
{
|
|
ok: true,
|
|
invitation: {
|
|
orderId,
|
|
token,
|
|
url: buildInvitationUrl(publicBaseUrl, token),
|
|
state: "awaiting_choice",
|
|
availableSlots: invitationPayload.available_slots,
|
|
},
|
|
},
|
|
200,
|
|
corsHeaders,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error && "status" in error) {
|
|
const httpError = error as { status: number; message: string };
|
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
|
}
|
|
|
|
return jsonResponse(
|
|
{
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : "Unexpected error",
|
|
},
|
|
500,
|
|
corsHeaders,
|
|
);
|
|
}
|
|
});
|