231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
import { createServiceClient } from "../_shared/chatbot.ts";
|
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
|
import {
|
|
getClientIp,
|
|
getCorsHeaders,
|
|
hashText,
|
|
jsonResponse,
|
|
preflightResponse,
|
|
readJsonBody,
|
|
requireRateLimit,
|
|
} from "../_shared/security.ts";
|
|
|
|
const MAX_BODY_BYTES = 8 * 1024;
|
|
const ALLOWED_ROLES = new Set(["manager", "logistician", "admin"]);
|
|
const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
|
|
const DELIVERY_TIME_ALIASES = new Map([
|
|
["До обеда", "Первая половина дня"],
|
|
["После обеда", "Вторая половина дня"],
|
|
]);
|
|
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
|
|
|
type UpdateDeliveryChoiceBody = {
|
|
orderGroupId?: string;
|
|
deliveryDate?: string;
|
|
deliveryTime?: string;
|
|
};
|
|
|
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
|
|
const getTodayKey = () => {
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: DELIVERY_TIMEZONE,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}).formatToParts(new Date());
|
|
|
|
const year = parts.find((part) => part.type === "year")?.value || "";
|
|
const month = parts.find((part) => part.type === "month")?.value || "";
|
|
const day = parts.find((part) => part.type === "day")?.value || "";
|
|
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const isWeekendDeliveryDate = (value: string) => {
|
|
if (!isValidDate(value)) {
|
|
return false;
|
|
}
|
|
|
|
const date = new Date(`${value}T12:00:00Z`);
|
|
const weekday = date.getUTCDay();
|
|
return weekday === 0 || weekday === 6;
|
|
};
|
|
|
|
const isAllowedDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey() && !isWeekendDeliveryDate(value);
|
|
|
|
const normalizeDeliveryTime = (value: string) => DELIVERY_TIME_ALIASES.get(value) || value;
|
|
|
|
const getBearerToken = (request: Request) => {
|
|
const authorization = request.headers.get("authorization") || "";
|
|
return authorization.toLowerCase().startsWith("bearer ")
|
|
? authorization.slice(7).trim()
|
|
: "";
|
|
};
|
|
|
|
const getUserRole = async (
|
|
supabase: ReturnType<typeof createServiceClient>,
|
|
accessToken: string,
|
|
) => {
|
|
const { data: authData, error: authError } = await supabase.auth.getUser(accessToken);
|
|
if (authError || !authData.user?.id) {
|
|
return null;
|
|
}
|
|
|
|
const { data: profile, error: profileError } = await supabase
|
|
.from("users")
|
|
.select("id, role_info:roles(name)")
|
|
.eq("id", authData.user.id)
|
|
.single();
|
|
|
|
if (profileError) {
|
|
throw profileError;
|
|
}
|
|
|
|
const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info;
|
|
return {
|
|
userId: authData.user.id,
|
|
role: roleInfo?.name || "",
|
|
};
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
try {
|
|
const { body } = await readJsonBody<UpdateDeliveryChoiceBody>(request, {
|
|
maxBytes: MAX_BODY_BYTES,
|
|
});
|
|
|
|
const orderGroupId = String(body.orderGroupId || "").trim();
|
|
const deliveryDate = String(body.deliveryDate || "").trim();
|
|
const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim());
|
|
|
|
if (!orderGroupId) {
|
|
return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders);
|
|
}
|
|
|
|
if (!isAllowedDeliveryDate(deliveryDate)) {
|
|
return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders);
|
|
}
|
|
|
|
if (!ALLOWED_DELIVERY_TIMES.has(deliveryTime)) {
|
|
return jsonResponse({ ok: false, error: "Выберите первую или вторую половину дня доставки" }, 400, corsHeaders);
|
|
}
|
|
|
|
const accessToken = getBearerToken(request);
|
|
if (!accessToken) {
|
|
return jsonResponse({ ok: false, error: "Authentication is required" }, 401, corsHeaders);
|
|
}
|
|
|
|
const supabase = createServiceClient();
|
|
const actor = await getUserRole(supabase, accessToken);
|
|
|
|
if (!actor || !ALLOWED_ROLES.has(actor.role)) {
|
|
return jsonResponse({ ok: false, error: "Forbidden" }, 403, corsHeaders);
|
|
}
|
|
|
|
const ipHash = await hashText(getClientIp(request));
|
|
await requireRateLimit(supabase, {
|
|
scope: "order-group-manual-delivery-choice",
|
|
key: `${actor.userId}:${ipHash}:${orderGroupId}`,
|
|
maxCount: 20,
|
|
windowSeconds: 600,
|
|
blockSeconds: 1800,
|
|
});
|
|
|
|
const { data: currentGroup, error: currentGroupError } = await supabase
|
|
.from("order_groups")
|
|
.select("id, delivery_status, delivery_invitation_id")
|
|
.eq("id", orderGroupId)
|
|
.single();
|
|
|
|
if (currentGroupError) {
|
|
throw currentGroupError;
|
|
}
|
|
|
|
const { data: group, error: groupUpdateError } = await supabase
|
|
.from("order_groups")
|
|
.update({
|
|
delivery_status: "agreed",
|
|
delivery_date: deliveryDate,
|
|
delivery_time: deliveryTime,
|
|
notification_status: "confirmed",
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", orderGroupId)
|
|
.select("*")
|
|
.single();
|
|
|
|
if (groupUpdateError) {
|
|
throw groupUpdateError;
|
|
}
|
|
|
|
if (currentGroup.delivery_invitation_id) {
|
|
const { error: invitationUpdateError } = await supabase
|
|
.from("delivery_invitations")
|
|
.update({
|
|
state: "agreed",
|
|
delivery_date: deliveryDate,
|
|
delivery_time: deliveryTime,
|
|
confirmed_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", currentGroup.delivery_invitation_id);
|
|
|
|
if (invitationUpdateError) {
|
|
throw invitationUpdateError;
|
|
}
|
|
}
|
|
|
|
await insertIntegrationEvent(supabase, {
|
|
order_id: null,
|
|
event_type: "order_group_manual_delivery_choice",
|
|
direction: "internal",
|
|
status: "success",
|
|
payload: {
|
|
order_group_id: orderGroupId,
|
|
actor_user_id: actor.userId,
|
|
actor_role: actor.role,
|
|
old_delivery_status: currentGroup.delivery_status || null,
|
|
new_delivery_status: "agreed",
|
|
delivery_date: deliveryDate,
|
|
delivery_time: deliveryTime,
|
|
},
|
|
});
|
|
|
|
return jsonResponse(
|
|
{
|
|
ok: true,
|
|
orderGroup: group,
|
|
},
|
|
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,
|
|
);
|
|
}
|
|
});
|