From 191f94b83413043e15ee901122de237f37dec2fb Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 20:37:55 +0700 Subject: [PATCH] fix: harden external bulk actions --- apps/web/src/pages/admin/external-sources.astro | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 241bf8a..2f091bc 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -51,14 +51,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.hostname ? esc(url.href) : ""; } catch { return ""; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } }; const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } }; - const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); }; + const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } syncBulk(); pages?.setAttribute("hidden", ""); if (message) fail(message); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const syncBulk = () => { const inputs = [...(list?.querySelectorAll("[data-external-select]") ?? [])]; const selected = inputs.filter(input => input.checked).length; if (bulk) bulk.hidden = selected === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && selected === inputs.length; selectAll.indeterminate = selected > 0 && selected < inputs.length; } const count = bulk?.querySelector("[data-external-bulk-count]"); if (count) count.textContent = `${selected} выбрано`; }; const hydrateBulk = () => { list?.querySelectorAll("[data-observation-id]").forEach(card => { if (card.querySelector("[data-external-select]")) return; const label = document.createElement("label"); label.innerHTML = ``; card.prepend(label); }); const fishSelect = bulk?.querySelector("[data-external-bulk-fish]"); const waterSelect = bulk?.querySelector("[data-external-bulk-waterbody]"); if (fishSelect && fishSelect.options.length === 1 && fishes.length) fishSelect.innerHTML = '' + options(fishes); if (waterSelect && waterSelect.options.length === 1 && waters.length) waterSelect.innerHTML = '' + options(waters); syncBulk(); }; const selectedCards = () => [...(list?.querySelectorAll("[data-external-select]:checked") ?? [])].map(input => input.closest("[data-observation-id]")).filter((card): card is HTMLElement => Boolean(card)); - bulk?.querySelector("[data-external-bulk-map]")?.addEventListener("click", async () => { const cards = selectedCards(); const fish_slug = bulk.querySelector("[data-external-bulk-fish]")?.value; const waterbody_slug = bulk.querySelector("[data-external-bulk-waterbody]")?.value; if (!cards.length) return; if (!fish_slug || !waterbody_slug) { fail("Выберите каноническую рыбу и водоём."); return; } try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-mapping`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({fish_slug,waterbody_slug,note:bulk.querySelector("[data-external-bulk-reason]")?.value.trim() || null,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Сопоставлено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного сопоставления."); } }); - bulk?.querySelector("[data-external-bulk-publish]")?.addEventListener("click", async () => { const cards = selectedCards(); if (!cards.length) return; try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-publish`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Опубликовано наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетной публикации."); } }); - bulk?.querySelector("[data-external-bulk-reject]")?.addEventListener("click", async () => { const cards = selectedCards(); const reason = bulk.querySelector("[data-external-bulk-reason]")?.value.trim(); if (!cards.length) return; if (!reason) { fail("Укажите причину отклонения."); return; } try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-reject`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({reason,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Отклонено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного отклонения."); } }); + const setBulkBusy = (busy: boolean) => bulk?.querySelectorAll("button").forEach(button => { button.disabled = busy; }); + bulk?.querySelector("[data-external-bulk-map]")?.addEventListener("click", async () => { const cards = selectedCards(); const fish_slug = bulk.querySelector("[data-external-bulk-fish]")?.value; const waterbody_slug = bulk.querySelector("[data-external-bulk-waterbody]")?.value; if (!cards.length) return; if (!fish_slug || !waterbody_slug) { fail("Выберите каноническую рыбу и водоём."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-mapping`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({fish_slug,waterbody_slug,note:bulk.querySelector("[data-external-bulk-reason]")?.value.trim() || null,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Сопоставлено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного сопоставления."); } finally { setBulkBusy(false); } }); + bulk?.querySelector("[data-external-bulk-publish]")?.addEventListener("click", async () => { const cards = selectedCards(); if (!cards.length) return; setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-publish`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Опубликовано наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетной публикации."); } finally { setBulkBusy(false); } }); + bulk?.querySelector("[data-external-bulk-reject]")?.addEventListener("click", async () => { const cards = selectedCards(); const reason = bulk.querySelector("[data-external-bulk-reason]")?.value.trim(); if (!cards.length) return; if (!reason) { fail("Укажите причину отклонения."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-reject`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({reason,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Отклонено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного отклонения."); } finally { setBulkBusy(false); } }); list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-external-select]")) syncBulk(); }); selectAll?.addEventListener("change", () => { list?.querySelectorAll("[data-external-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); }); const options = (items: Record[], selected?: unknown) => items.map(item => ``).join("");