/** * Network requests: duties and calendar events. */ import { FETCH_TIMEOUT_MS } from "./constants.js"; import { getInitData } from "./auth.js"; import { state } from "./dom.js"; import { t } from "./i18n.js"; /** * Build fetch options with init data header, Accept-Language and timeout abort. * Optional external signal (e.g. from loadMonth) aborts this request when triggered. * @param {string} initData - Telegram init data * @param {AbortSignal} [externalSignal] - when aborted, cancels this request * @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }} */ export function buildFetchOptions(initData, externalSignal) { const headers = {}; if (initData) headers["X-Telegram-Init-Data"] = initData; headers["Accept-Language"] = state.lang || "ru"; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const onAbort = () => controller.abort(); const cleanup = () => { clearTimeout(timeoutId); if (externalSignal) externalSignal.removeEventListener("abort", onAbort); }; if (externalSignal) { if (externalSignal.aborted) { cleanup(); controller.abort(); } else { externalSignal.addEventListener("abort", onAbort); } } return { headers, signal: controller.signal, cleanup }; } /** * GET request to API with init data, Accept-Language, timeout. * Caller checks res.ok, res.status, res.json(). * @param {string} path - e.g. "/api/duties" * @param {{ from?: string, to?: string }} params - query params * @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation * @returns {Promise} - raw response */ export async function apiGet(path, params = {}, options = {}) { const base = window.location.origin; const query = new URLSearchParams(params).toString(); const url = query ? `${base}${path}?${query}` : `${base}${path}`; const initData = getInitData(); const opts = buildFetchOptions(initData, options.signal); try { const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); return res; } finally { opts.cleanup(); } } /** * Fetch duties for date range. Throws ACCESS_DENIED error on 403. * AbortError is rethrown when the request is cancelled (e.g. stale loadMonth). * @param {string} from - YYYY-MM-DD * @param {string} to - YYYY-MM-DD * @param {AbortSignal} [signal] - optional signal to cancel the request * @returns {Promise} */ export async function fetchDuties(from, to, signal) { const res = await apiGet("/api/duties", { from, to }, { signal }); if (res.status === 403) { let detail = t(state.lang, "access_denied"); try { const body = await res.json(); if (body && body.detail !== undefined) { detail = typeof body.detail === "string" ? body.detail : (body.detail.msg || JSON.stringify(body.detail)); } } catch (parseErr) { /* ignore */ } const err = new Error("ACCESS_DENIED"); err.serverDetail = detail; throw err; } if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); return res.json(); } /** * Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403. * Rethrows AbortError when the request is cancelled (e.g. stale loadMonth). * @param {string} from - YYYY-MM-DD * @param {string} to - YYYY-MM-DD * @param {AbortSignal} [signal] - optional signal to cancel the request * @returns {Promise} */ export async function fetchCalendarEvents(from, to, signal) { try { const res = await apiGet("/api/calendar-events", { from, to }, { signal }); if (!res.ok) return []; return res.json(); } catch (e) { if (e.name === "AbortError") throw e; return []; } }