This commit is contained in:
parent
c3a35a31d4
commit
209e4e145f
5 changed files with 238 additions and 115 deletions
|
|
@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
|
||||||
import { LandTab } from "@/components/site-finder/LandTab";
|
import { LandTab } from "@/components/site-finder/LandTab";
|
||||||
import { MarketTab } from "@/components/site-finder/MarketTab";
|
import { MarketTab } from "@/components/site-finder/MarketTab";
|
||||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
type PoiCategoryKey,
|
type PoiCategoryKey,
|
||||||
} from "@/lib/api/weightProfiles";
|
} from "@/lib/api/weightProfiles";
|
||||||
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
||||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
||||||
const SiteMap = dynamic(
|
const SiteMap = dynamic(
|
||||||
|
|
@ -129,16 +130,16 @@ function SiteFinderContent() {
|
||||||
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
||||||
const weightsChangeInitializedRef = useRef(false);
|
const weightsChangeInitializedRef = useRef(false);
|
||||||
|
|
||||||
const [profileUserId, setProfileUserId] = useState<string>(() =>
|
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
|
||||||
typeof window === "undefined"
|
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
|
||||||
? ""
|
// который сервер не читает с #437) — оба недостижимы без DevTools.
|
||||||
: (localStorage.getItem("admin_user_id") ?? ""),
|
//
|
||||||
);
|
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
|
||||||
const [adminToken] = useState<string>(() =>
|
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
|
||||||
typeof window === "undefined"
|
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
|
||||||
? ""
|
// user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль
|
||||||
: (localStorage.getItem("admin_token") ?? ""),
|
// молча не применялся бы, а UI показывал бы его ползунки.
|
||||||
);
|
const profileUserId = useMe().data?.username ?? "";
|
||||||
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
||||||
// игнорирует initializer после первого render — не тратим CPU).
|
// игнорирует initializer после первого render — не тратим CPU).
|
||||||
const [tab, setTabState] = useState<TabId>(() => {
|
const [tab, setTabState] = useState<TabId>(() => {
|
||||||
|
|
@ -192,12 +193,7 @@ function SiteFinderContent() {
|
||||||
setIsochrones(undefined);
|
setIsochrones(undefined);
|
||||||
mutate({
|
mutate({
|
||||||
cad: currentData.cad_num,
|
cad: currentData.cad_num,
|
||||||
options:
|
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
|
||||||
profileId != null
|
|
||||||
? { profileId }
|
|
||||||
: currentProfileUserId
|
|
||||||
? { profileUserId: currentProfileUserId, weights }
|
|
||||||
: { weights },
|
|
||||||
});
|
});
|
||||||
// mutate is stable from useMutation — safe to omit from deps.
|
// mutate is stable from useMutation — safe to omit from deps.
|
||||||
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||||
|
|
@ -216,12 +212,11 @@ function SiteFinderContent() {
|
||||||
// slider values are always respected even without a saved profile (#201).
|
// slider values are always respected even without a saved profile (#201).
|
||||||
mutate({
|
mutate({
|
||||||
cad: cadNum,
|
cad: cadNum,
|
||||||
options:
|
options: buildAnalyzeOptions(
|
||||||
activeProfileId != null
|
currentWeights,
|
||||||
? { profileId: activeProfileId }
|
activeProfileId,
|
||||||
: profileUserId
|
profileUserId,
|
||||||
? { profileUserId, weights: currentWeights }
|
),
|
||||||
: { weights: currentWeights },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,46 +324,9 @@ function SiteFinderContent() {
|
||||||
|
|
||||||
{/* Weight profile panel — collapsible, below header */}
|
{/* Weight profile panel — collapsible, below header */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
|
|
||||||
{!!adminToken && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
User ID (для профилей):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={profileUserId}
|
|
||||||
placeholder="user-abc"
|
|
||||||
style={{
|
|
||||||
padding: "4px 8px",
|
|
||||||
fontSize: 12,
|
|
||||||
border: "1px solid #d1d5db",
|
|
||||||
borderRadius: 6,
|
|
||||||
width: 180,
|
|
||||||
}}
|
|
||||||
onChange={(e) => {
|
|
||||||
setProfileUserId(e.target.value);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem("admin_user_id", e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<WeightProfilePanel
|
<WeightProfilePanel
|
||||||
currentWeights={currentWeights}
|
currentWeights={currentWeights}
|
||||||
onWeightsChange={handleWeightsChange}
|
onWeightsChange={handleWeightsChange}
|
||||||
userId={profileUserId || undefined}
|
|
||||||
adminToken={adminToken || undefined}
|
|
||||||
/>
|
/>
|
||||||
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
||||||
weights change (data already loaded, pendingWeightsChange set). */}
|
weights change (data already loaded, pendingWeightsChange set). */}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||||
|
import { useMe } from "@/lib/useMe";
|
||||||
import {
|
import {
|
||||||
POI_CATEGORIES,
|
POI_CATEGORIES,
|
||||||
POI_DEFAULT_WEIGHTS,
|
POI_DEFAULT_WEIGHTS,
|
||||||
|
|
@ -29,13 +30,6 @@ interface Props {
|
||||||
weights: Record<PoiCategoryKey, number>,
|
weights: Record<PoiCategoryKey, number>,
|
||||||
profileId: number | null,
|
profileId: number | null,
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
|
||||||
* If provided, enables save/load from DB.
|
|
||||||
* Must be non-empty for CRUD functionality.
|
|
||||||
*/
|
|
||||||
userId?: string;
|
|
||||||
/** Admin token for CRUD API calls. */
|
|
||||||
adminToken?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -66,12 +60,7 @@ function weightsEqual(
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function WeightProfilePanel({
|
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||||
currentWeights,
|
|
||||||
onWeightsChange,
|
|
||||||
userId,
|
|
||||||
adminToken,
|
|
||||||
}: Props) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Local draft weights — editable before "Применить"
|
// Local draft weights — editable before "Применить"
|
||||||
|
|
@ -89,11 +78,19 @@ export function WeightProfilePanel({
|
||||||
const [saveName, setSaveName] = useState("");
|
const [saveName, setSaveName] = useState("");
|
||||||
const [saveDefault, setSaveDefault] = useState(false);
|
const [saveDefault, setSaveDefault] = useState(false);
|
||||||
|
|
||||||
// Profiles query (only when userId + adminToken provided)
|
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
|
||||||
const canUseCrud = !!userId && !!adminToken;
|
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
|
||||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
|
||||||
|
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
|
||||||
|
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
|
||||||
|
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
|
||||||
|
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const userId = me?.username ?? "";
|
||||||
|
const canUseCrud = !!userId;
|
||||||
|
const profilesQuery = useWeightProfiles(userId);
|
||||||
|
|
||||||
const createMutation = useCreateProfile(adminToken ?? "");
|
const createMutation = useCreateProfile();
|
||||||
|
|
||||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -117,7 +114,7 @@ export function WeightProfilePanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProfile = useCallback(async () => {
|
const handleSaveProfile = useCallback(async () => {
|
||||||
if (!canUseCrud || !userId || !adminToken) {
|
if (!canUseCrud || !userId) {
|
||||||
setShowSaveDialog(false);
|
setShowSaveDialog(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -136,15 +133,7 @@ export function WeightProfilePanel({
|
||||||
} catch {
|
} catch {
|
||||||
// Error visible through createMutation.error
|
// Error visible through createMutation.error
|
||||||
}
|
}
|
||||||
}, [
|
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
|
||||||
canUseCrud,
|
|
||||||
userId,
|
|
||||||
adminToken,
|
|
||||||
saveName,
|
|
||||||
draft,
|
|
||||||
saveDefault,
|
|
||||||
createMutation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -242,6 +231,7 @@ export function WeightProfilePanel({
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<label
|
<label
|
||||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||||
|
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
|
||||||
>
|
>
|
||||||
Профиль:
|
Профиль:
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -278,10 +268,13 @@ export function WeightProfilePanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hint when no crud */}
|
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
|
||||||
|
Caddy basic_auth) — остаётся для dev-запуска без прокси, где
|
||||||
|
/api/v1/me отвечает 401. Текст называет причину, а не действие:
|
||||||
|
вводить тут больше нечего. */}
|
||||||
{!canUseCrud && (
|
{!canUseCrud && (
|
||||||
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
||||||
Укажите User ID и Admin Token для сохранения профилей.
|
Пользователь не определён — сохранение профилей недоступно.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -362,6 +355,9 @@ export function WeightProfilePanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SectionLabel>Новый профиль</SectionLabel>
|
<SectionLabel>Новый профиль</SectionLabel>
|
||||||
|
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
|
||||||
|
Сохранится для пользователя {userId} — другие его не увидят.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* #2782 — CRUD профилей весов был недостижим никому без DevTools.
|
||||||
|
*
|
||||||
|
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
|
||||||
|
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
|
||||||
|
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) — то есть
|
||||||
|
* фича стояла за признаком, который ничего не решал.
|
||||||
|
*
|
||||||
|
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
|
||||||
|
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
|
||||||
|
* 2) в запросы CRUD не уходит X-Admin-Token;
|
||||||
|
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
|
||||||
|
* считает по системным весам, рапортуя source="profile").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { WeightProfilePanel } from "../WeightProfilePanel";
|
||||||
|
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
|
||||||
|
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE = {
|
||||||
|
id: 1,
|
||||||
|
user_id: "admin",
|
||||||
|
profile_name: "Мой профиль",
|
||||||
|
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
|
||||||
|
is_default: true,
|
||||||
|
description: null,
|
||||||
|
created_at: "2026-05-15T05:30:51Z",
|
||||||
|
updated_at: "2026-05-15T05:30:51Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<WeightProfilePanel
|
||||||
|
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
|
||||||
|
onWeightsChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
|
||||||
|
async function openPanel() {
|
||||||
|
const { default: userEvent } = await import("@testing-library/user-event");
|
||||||
|
await userEvent.setup().click(screen.getByText("POI Веса"));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockImplementation(async (input) => {
|
||||||
|
const url = typeof input === "string" ? input : String(input);
|
||||||
|
if (url.includes("/api/v1/me")) {
|
||||||
|
return jsonResponse({
|
||||||
|
username: "admin",
|
||||||
|
role: "admin",
|
||||||
|
allowed_paths: ["/**"],
|
||||||
|
deny_paths: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/weight-profiles")) {
|
||||||
|
return jsonResponse([PROFILE]);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
|
||||||
|
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Профиль:")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Admin Token/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
|
||||||
|
renderPanel();
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const crudCall = fetchMock.mock.calls.find(([input]) =>
|
||||||
|
String(input).includes("/weight-profiles"),
|
||||||
|
);
|
||||||
|
expect(crudCall).toBeTruthy();
|
||||||
|
const headers = new Headers(
|
||||||
|
(crudCall?.[1] as RequestInit | undefined)?.headers,
|
||||||
|
);
|
||||||
|
expect(headers.has("X-Admin-Token")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
|
||||||
|
const weights = { ...POI_DEFAULT_WEIGHTS };
|
||||||
|
|
||||||
|
it("выбранный профиль уходит вместе с profileUserId", () => {
|
||||||
|
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
|
||||||
|
profileId: 7,
|
||||||
|
profileUserId: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
|
||||||
|
const options = buildAnalyzeOptions(weights, 7, "");
|
||||||
|
expect(options.profileId).toBeUndefined();
|
||||||
|
expect(options.weights).toEqual(weights);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
|
||||||
|
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
|
||||||
|
profileUserId: "admin",
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
||||||
|
|
||||||
export type FetchStatus =
|
export type FetchStatus =
|
||||||
| "ready"
|
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
|
||||||
| "fetching"
|
|
||||||
| "failed"
|
|
||||||
| "not_in_nspd"
|
|
||||||
| "invalid_format";
|
|
||||||
|
|
||||||
export interface FetchStatusResponse {
|
export interface FetchStatusResponse {
|
||||||
status: FetchStatus;
|
status: FetchStatus;
|
||||||
|
|
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
|
||||||
weights?: Record<string, number> | null;
|
weights?: Record<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать options для POST /analyze из состояния панели весов POI.
|
||||||
|
*
|
||||||
|
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
|
||||||
|
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
|
||||||
|
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
|
||||||
|
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
|
||||||
|
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
|
||||||
|
* #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из
|
||||||
|
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
|
||||||
|
*
|
||||||
|
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
|
||||||
|
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
|
||||||
|
*/
|
||||||
|
export function buildAnalyzeOptions(
|
||||||
|
weights: Record<string, number>,
|
||||||
|
profileId: number | null,
|
||||||
|
profileUserId: string,
|
||||||
|
): AnalyzeOptions {
|
||||||
|
if (profileId != null && profileUserId) {
|
||||||
|
return { profileId, profileUserId };
|
||||||
|
}
|
||||||
|
if (profileUserId) {
|
||||||
|
return { profileUserId, weights };
|
||||||
|
}
|
||||||
|
return { weights };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
|
||||||
|
|
||||||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||||||
|
|
||||||
function profilesHeaders(adminToken: string): HeadersInit {
|
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||||||
return { "X-Admin-Token": adminToken };
|
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
|
||||||
}
|
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
|
||||||
|
// с корректным токеном, с мусорным и без заголовка вовсе.
|
||||||
|
//
|
||||||
|
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
|
||||||
|
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
|
||||||
|
// (подставленный клиентом X-Authenticated-User туда же не проходит);
|
||||||
|
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
|
||||||
|
|
||||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List all weight profiles for a given user_id. */
|
/** List all weight profiles for a given user_id. */
|
||||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
export function useWeightProfiles(userId: string) {
|
||||||
return useQuery<WeightProfile[]>({
|
return useQuery<WeightProfile[]>({
|
||||||
queryKey: ["weight-profiles", userId],
|
queryKey: ["weight-profiles", userId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<WeightProfile[]>(
|
apiFetch<WeightProfile[]>(
|
||||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
enabled: !!userId && !!adminToken,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new weight profile. Invalidates the list query on success. */
|
/** Create a new weight profile. Invalidates the list query on success. */
|
||||||
export function useCreateProfile(adminToken: string) {
|
export function useCreateProfile() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||||||
mutationFn: (payload) =>
|
mutationFn: (payload) =>
|
||||||
apiFetch<WeightProfile>(BASE_PATH, {
|
apiFetch<WeightProfile>(BASE_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
|
|
@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update an existing weight profile by id. */
|
/** Update an existing weight profile by id. */
|
||||||
export function useUpdateProfile(
|
export function useUpdateProfile(userId: string, profileId: number) {
|
||||||
userId: string,
|
|
||||||
profileId: number,
|
|
||||||
adminToken: string,
|
|
||||||
) {
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||||
mutationFn: (payload) =>
|
mutationFn: (payload) =>
|
||||||
|
|
@ -143,7 +141,6 @@ export function useUpdateProfile(
|
||||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -154,16 +151,13 @@ export function useUpdateProfile(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||||
export function useDeleteProfile(userId: string, adminToken: string) {
|
export function useDeleteProfile(userId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<void, Error, number>({
|
return useMutation<void, Error, number>({
|
||||||
mutationFn: (profileId) =>
|
mutationFn: (profileId) =>
|
||||||
apiFetch<void>(
|
apiFetch<void>(
|
||||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||||
{
|
{ method: "DELETE" },
|
||||||
method: "DELETE",
|
|
||||||
headers: profilesHeaders(adminToken),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue