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; const MAX_SLOTS = 14; 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).slice(0, MAX_SLOTS) : 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() + 3 * 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(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() + 3 * 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, ); } });