supersam/supabase/functions/create-delivery-invitation/index.ts

211 lines
6.5 KiB
TypeScript

import {
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;
orderNumber?: string;
customerName?: string;
customerPhone?: string;
customerMessenger?: string;
availableSlots?: string[];
source?: string;
};
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) {
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
}
const supabase = createServiceClient();
await requireRateLimit(supabase, {
scope: "delivery-invitation-create",
key: body.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", body.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", body.orderId)
.maybeSingle();
if (existingInvitationError) {
throw existingInvitationError;
}
if (currentOrder.delivery_flow_started_at || existingInvitation) {
return jsonResponse(
{
ok: true,
alreadyStarted: true,
invitation: existingInvitation
? {
orderId: body.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: body.orderId,
state: "awaiting_choice",
},
},
200,
corsHeaders,
);
}
const invitationPayload = {
order_id: body.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", body.orderId);
if (updateError) {
throw updateError;
}
const { error: historyError } = await supabase.from("order_history").insert({
order_id: body.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: body.orderId,
event_type: "delivery_invitation_created",
direction: "outbound",
status: "success",
payload: {
token_hash: tokenHash,
available_slots: invitationPayload.available_slots,
},
});
const publicBaseUrl = resolvePublicAppUrl(request);
return jsonResponse(
{
ok: true,
invitation: {
orderId: body.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,
);
}
});