supersam/supabase/functions/confirm-delivery-choice/index.ts

360 lines
10 KiB
TypeScript

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<ConfirmBody>(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,
);
}
});