import { getOrderUpdateForDeliveryInvitationAction, hashInvitationToken, isActiveInvitationState, isInvitationExpired, } from "../_shared/delivery-invitations.ts"; import { isValidUuid, requireUuid } from "../_shared/security.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { getClientIp, getCorsHeaders, hashText, jsonResponse, preflightResponse, readJsonBody, requireRateLimit, requireSameOrigin, } from "../_shared/security.ts"; const MAX_BODY_BYTES = 8 * 1024; type ConfirmBody = { token?: string; deliveryDate?: string; deliveryTime?: string; }; const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); const resolveRequestedSlot = ( invitation: { delivery_date?: string | null; delivery_time?: string | null; available_slots?: string[] | null; }, body: ConfirmBody, ) => { const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); if (!deliveryDate || !deliveryTime || !isValidDate(deliveryDate)) { return null; } const slotLabel = `${deliveryDate}, ${deliveryTime}`; const availableSlots = invitation.available_slots || []; if (availableSlots.length > 0 && !availableSlots.includes(slotLabel)) { return null; } return { deliveryDate, deliveryTime }; }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { return preflightResponse(request, "public"); } if (request.method !== "POST") { return jsonResponse({ ok: false, error: "Method not allowed" }, 405); } const corsHeaders = getCorsHeaders(request, "public"); if (!corsHeaders) { return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } const allowedOriginsForCsrf = ((): string[] => { const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean); const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || ""; return [...envOrigins, appUrl].filter(Boolean); })(); if (!requireSameOrigin(request, allowedOriginsForCsrf)) { const origin = request.headers.get("origin") || ""; if (origin) { return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders); } } try { const { body } = await readJsonBody(request, { maxBytes: MAX_BODY_BYTES, }); if (!body.token) { return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); } if (body.orderGroupId) { try { requireUuid(body.orderGroupId, "orderGroupId"); } catch (e) { return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders); } } const tokenHash = await hashInvitationToken(body.token); const supabase = createServiceClient(); const ipHash = await hashText(getClientIp(request)); await requireRateLimit(supabase, { scope: "invitation-confirm", key: `${ipHash}:${tokenHash.slice(0, 16)}`, maxCount: 5, windowSeconds: 600, blockSeconds: 3600, }); const { data: invitation, error: invitationError } = await supabase .from("delivery_invitations") .select("*") .eq("token_hash", tokenHash) .single(); if (invitationError) { if (invitationError.code === "PGRST116") { return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders); } throw invitationError; } if (isInvitationExpired(invitation)) { return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); } if (invitation.order_group_id) { const { data: currentGroup, error: groupError } = await supabase .from("order_groups") .select("id, delivery_status") .eq("id", invitation.order_group_id) .single(); if (groupError) { throw groupError; } if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") { return jsonResponse( { ok: false, error: "Invitation is no longer active", }, 409, corsHeaders, ); } const requestedSlot = resolveRequestedSlot(invitation, body); if (!requestedSlot) { return jsonResponse( { ok: false, error: "Selected slot is not available", }, 422, corsHeaders, ); } const { error: invitationUpdateError } = await supabase .from("delivery_invitations") .update({ state: "agreed", delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, confirmed_at: new Date().toISOString(), access_count: (invitation.access_count || 0) + 1, last_accessed_at: new Date().toISOString(), }) .eq("id", invitation.id); if (invitationUpdateError) { throw invitationUpdateError; } const { error: groupUpdateError } = await supabase .from("order_groups") .update({ delivery_status: "agreed", delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, notification_status: "confirmed", updated_at: new Date().toISOString(), }) .eq("id", invitation.order_group_id); if (groupUpdateError) { throw groupUpdateError; } // Log: client confirmed delivery choice await supabase.from("action_logs").insert({ order_group_id: invitation.order_group_id, action: "client_confirmed", old_value: currentGroup.delivery_status, new_value: "agreed", details: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, source: "auto", }, }); await insertIntegrationEvent(supabase, { order_id: null, event_type: "delivery_choice_confirmed", direction: "inbound", status: "success", payload: { order_group_id: invitation.order_group_id, delivery_invitation_id: invitation.id, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, }, }); return jsonResponse( { ok: true, orderGroupId: invitation.order_group_id, deliveryStatus: "agreed", }, 200, corsHeaders, ); } const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status") .eq("id", invitation.order_id) .single(); if (orderError) { throw orderError; } if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) { return jsonResponse( { ok: false, error: "Invitation is no longer active", }, 409, corsHeaders, ); } const requestedSlot = resolveRequestedSlot(invitation, body); if (!requestedSlot) { return jsonResponse( { ok: false, error: "Selected slot is not available", }, 422, corsHeaders, ); } const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice"); const { error: invitationUpdateError } = await supabase .from("delivery_invitations") .update({ state: "agreed", delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, confirmed_at: new Date().toISOString(), access_count: (invitation.access_count || 0) + 1, last_accessed_at: new Date().toISOString(), }) .eq("id", invitation.id); if (invitationUpdateError) { throw invitationUpdateError; } const { error: orderUpdateError } = await supabase .from("orders") .update({ status: orderUpdate?.status, delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, }) .eq("id", invitation.order_id); if (orderUpdateError) { throw orderUpdateError; } const { error: slotError } = await supabase.from("delivery_slots").insert({ order_id: invitation.order_id, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, logistician_id: null, status: "confirmed_by_client", }); if (slotError) { throw slotError; } const { error: historyError } = await supabase.from("order_history").insert({ order_id: invitation.order_id, action: "Подтверждение выбора доставки клиентом", old_status: currentOrder.status, new_status: orderUpdate?.status, metadata: { old_delivery_agreement_status: currentOrder.delivery_agreement_status, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, }, }); if (historyError) { throw historyError; } await insertIntegrationEvent(supabase, { order_id: invitation.order_id, event_type: "delivery_choice_confirmed", direction: "inbound", status: "success", payload: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, }, }); return jsonResponse( { ok: true, orderId: invitation.order_id, status: orderUpdate?.status, deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, }, 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, ); } });