fix(site-finder): вернуть доступ к профилям весов — владелец из сессии (#2782) #2788
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 { MarketTab } from "@/components/site-finder/MarketTab";
|
||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
POI_DEFAULT_WEIGHTS,
|
||||
type PoiCategoryKey,
|
||||
} from "@/lib/api/weightProfiles";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
||||
const SiteMap = dynamic(
|
||||
|
|
@ -129,16 +130,16 @@ function SiteFinderContent() {
|
|||
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
||||
const weightsChangeInitializedRef = useRef(false);
|
||||
|
||||
const [profileUserId, setProfileUserId] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_user_id") ?? ""),
|
||||
);
|
||||
const [adminToken] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_token") ?? ""),
|
||||
);
|
||||
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
|
||||
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
|
||||
// который сервер не читает с #437) — оба недостижимы без DevTools.
|
||||
//
|
||||
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
|
||||
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
|
||||
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
|
||||
// user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль
|
||||
// молча не применялся бы, а UI показывал бы его ползунки.
|
||||
const profileUserId = useMe().data?.username ?? "";
|
||||
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
||||
// игнорирует initializer после первого render — не тратим CPU).
|
||||
const [tab, setTabState] = useState<TabId>(() => {
|
||||
|
|
@ -192,12 +193,7 @@ function SiteFinderContent() {
|
|||
setIsochrones(undefined);
|
||||
mutate({
|
||||
cad: currentData.cad_num,
|
||||
options:
|
||||
profileId != null
|
||||
? { profileId }
|
||||
: currentProfileUserId
|
||||
? { profileUserId: currentProfileUserId, weights }
|
||||
: { weights },
|
||||
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
|
||||
});
|
||||
// mutate is stable from useMutation — safe to omit from deps.
|
||||
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||
|
|
@ -216,12 +212,11 @@ function SiteFinderContent() {
|
|||
// slider values are always respected even without a saved profile (#201).
|
||||
mutate({
|
||||
cad: cadNum,
|
||||
options:
|
||||
activeProfileId != null
|
||||
? { profileId: activeProfileId }
|
||||
: profileUserId
|
||||
? { profileUserId, weights: currentWeights }
|
||||
: { weights: currentWeights },
|
||||
options: buildAnalyzeOptions(
|
||||
currentWeights,
|
||||
activeProfileId,
|
||||
profileUserId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -329,46 +324,9 @@ function SiteFinderContent() {
|
|||
|
||||
{/* Weight profile panel — collapsible, below header */}
|
||||
<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
|
||||
currentWeights={currentWeights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
userId={profileUserId || undefined}
|
||||
adminToken={adminToken || undefined}
|
||||
/>
|
||||
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
||||
weights change (data already loaded, pendingWeightsChange set). */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
import {
|
||||
POI_CATEGORIES,
|
||||
POI_DEFAULT_WEIGHTS,
|
||||
|
|
@ -29,13 +30,6 @@ interface Props {
|
|||
weights: Record<PoiCategoryKey, number>,
|
||||
profileId: number | null,
|
||||
) => 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 ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -66,12 +60,7 @@ function weightsEqual(
|
|||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function WeightProfilePanel({
|
||||
currentWeights,
|
||||
onWeightsChange,
|
||||
userId,
|
||||
adminToken,
|
||||
}: Props) {
|
||||
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Local draft weights — editable before "Применить"
|
||||
|
|
@ -89,11 +78,19 @@ export function WeightProfilePanel({
|
|||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDefault, setSaveDefault] = useState(false);
|
||||
|
||||
// Profiles query (only when userId + adminToken provided)
|
||||
const canUseCrud = !!userId && !!adminToken;
|
||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
||||
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
|
||||
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
|
||||
// взять: поле ввода удалили в #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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -117,7 +114,7 @@ export function WeightProfilePanel({
|
|||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
if (!canUseCrud || !userId || !adminToken) {
|
||||
if (!canUseCrud || !userId) {
|
||||
setShowSaveDialog(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -136,15 +133,7 @@ export function WeightProfilePanel({
|
|||
} catch {
|
||||
// Error visible through createMutation.error
|
||||
}
|
||||
}, [
|
||||
canUseCrud,
|
||||
userId,
|
||||
adminToken,
|
||||
saveName,
|
||||
draft,
|
||||
saveDefault,
|
||||
createMutation,
|
||||
]);
|
||||
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -242,6 +231,7 @@ export function WeightProfilePanel({
|
|||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<label
|
||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
|
||||
>
|
||||
Профиль:
|
||||
</label>
|
||||
|
|
@ -278,10 +268,13 @@ export function WeightProfilePanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint when no crud */}
|
||||
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
|
||||
Caddy basic_auth) — остаётся для dev-запуска без прокси, где
|
||||
/api/v1/me отвечает 401. Текст называет причину, а не действие:
|
||||
вводить тут больше нечего. */}
|
||||
{!canUseCrud && (
|
||||
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
||||
Укажите User ID и Admin Token для сохранения профилей.
|
||||
Пользователь не определён — сохранение профилей недоступно.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -362,6 +355,9 @@ export function WeightProfilePanel({
|
|||
}}
|
||||
>
|
||||
<SectionLabel>Новый профиль</SectionLabel>
|
||||
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
|
||||
Сохранится для пользователя {userId} — другие его не увидят.
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
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.
|
||||
|
||||
export type FetchStatus =
|
||||
| "ready"
|
||||
| "fetching"
|
||||
| "failed"
|
||||
| "not_in_nspd"
|
||||
| "invalid_format";
|
||||
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
|
||||
|
||||
export interface FetchStatusResponse {
|
||||
status: FetchStatus;
|
||||
|
|
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
|
|||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
|
|||
|
||||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||||
|
||||
function profilesHeaders(adminToken: string): HeadersInit {
|
||||
return { "X-Admin-Token": adminToken };
|
||||
}
|
||||
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||||
// `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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all weight profiles for a given user_id. */
|
||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
||||
export function useWeightProfiles(userId: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${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. */
|
||||
export function useCreateProfile(adminToken: string) {
|
||||
export function useCreateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(BASE_PATH, {
|
||||
method: "POST",
|
||||
headers: profilesHeaders(adminToken),
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
onSuccess: (_, variables) => {
|
||||
|
|
@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
|
|||
}
|
||||
|
||||
/** Update an existing weight profile by id. */
|
||||
export function useUpdateProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
adminToken: string,
|
||||
) {
|
||||
export function useUpdateProfile(userId: string, profileId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||
mutationFn: (payload) =>
|
||||
|
|
@ -143,7 +141,6 @@ export function useUpdateProfile(
|
|||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: profilesHeaders(adminToken),
|
||||
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). */
|
||||
export function useDeleteProfile(userId: string, adminToken: string) {
|
||||
export function useDeleteProfile(userId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (profileId) =>
|
||||
apiFetch<void>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: profilesHeaders(adminToken),
|
||||
},
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue