From 4ae2954f97068ed9c12dd83ef4a86d9f90095dc0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 14:55:03 +0700 Subject: [PATCH] =?UTF-8?q?fix(backups):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11a.md | 8 ++ packages/contracts/src/backups.ts | 47 ++++++++- shared/api/backups.test.ts | 82 ++++++++++++++++ shared/api/backups.ts | 153 +++++++++--------------------- 6 files changed, 189 insertions(+), 113 deletions(-) create mode 100644 shared/api/backups.test.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index e02a85db..d8f453ae 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.24 — окончательная локальная приёмка сертификатов и строгие API-контракты резервных копий. Issue runner исправлен в 11А.23. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.25 — независимые каталоги резервных копий, source/mode guard, права и состояния данных. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 98e84e7e..baa2aa59 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.23; пользователи/контейнеры и UI сертификатов приняты локально, runner исправлен; далее 11А.24 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.24; пользователи/контейнеры/сертификаты приняты локально, API резервных копий строгий; далее 11А.25 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11А.24 — локальная приёмка сертификатов и API резервных копий (2026-10-01) + +- Сертификаты приняты локально по 11А.14–11А.23: state/unknown/частичные сбои, URL, права, renew/ACME формы, checkpoint выпуска и runner, 48 main + 16 context E2E и ПК/390/320 снимки. После runner исправления backend профиль/10 fault unit/5 DTO unit, frontend API/poll/checkpoint и production сборки прошли. Реальный PostgreSQL, ACME, RouterOS, восстановление running после рестарта и распределённая блокировка остаются внешней приёмкой. PEM/private key import/export не реализованы; JSON metadata snapshot не обязателен для принятого объёма. +- Backups API использует общие Zod-контракты: список без дублей, обязательное storage, корректные даты/размеры; job counters и результаты согласованы, terminal имеет время окончания, чужой ID не принимается. Partial done с failures остаётся частичным результатом. Удалён неиспользуемый createBackups, ожидавший синхронные created вместо фактического job ack. +- Настройки и ответы restore/test/sync проверяются, запросы принимают AbortSignal и 30s срок. DELETE требует фактический HTTP 204, IDs кодируются. Некорректный запрос создания/расписания отклоняется до HTTP; известные false/0/empty не подменяются. +- 6 API fault unit, frontend TypeScript/профильный ESLint, backend и frontend production build прошли. Backups UI/операции ещё не приняты: Promise.all, отсутствие source/mode/RBAC guard, сброс черновика, повтор POST, интервал polling и ошибки событий требуют следующих блоков. Реальные PostgreSQL/RouterOS/S3 не проверялись. +- **Следующий блок 11А.25:** независимые подтверждённые каталоги backups/servers/schedule/storage, source guard и read/write права, initial/empty/stale/partial состояния. Далее формы настроек, создание/опрос, restore/delete/download и визуальная приёмка. + ## Блок 11А.23 — достоверный результат backend issue runner (2026-10-01) - События и ошибки записи прогресса отделены от результата ACME/RouterOS. Ошибка appendEvent после успеха не записывает failed; ошибка итоговой записи остаётся ошибкой наблюдения, без повторного импорта. Очередь перехватывает setup rejection, process lock освобождается при любой ошибке. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 524c9c88..1f2c922c 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -297,3 +297,11 @@ - Реальная фаза dns_a_sync добавлена в контракт. Диагностические callback строки Cloudflare сохраняются отдельными событиями, старые DB строки читаются как dns_a_sync. Порядок ACME/DNS/RouterOS не изменён. - 10 fault unit runner, 5 DTO unit, полный backend test:certificates/build, 14 frontend API/poll/checkpoint unit, TypeScript и профильный ESLint прошли. PostgreSQL/ACME/RouterOS остаются внешней приёмкой; lock действует в одном процессе, распределённый lease не реализован. - **Следующий блок 11А.24:** закрыть локальную приёмку сертификатов с явными ограничениями и начать строгие API-контракты резервных копий. JSON metadata snapshot необязателен; PEM/private key import/export остаётся недоступным. + +## Блок 11А.24 — локальная приёмка сертификатов и API резервных копий (2026-10-01) + +- Сертификаты приняты локально по 11А.14–11А.23: state/unknown/частичные сбои, URL, права, renew/ACME формы, checkpoint выпуска и runner, 48 main + 16 context E2E и ПК/390/320 снимки. После runner исправления backend профиль/10 fault unit/5 DTO unit, frontend API/poll/checkpoint и production сборки прошли. Реальный PostgreSQL, ACME, RouterOS, восстановление running после рестарта и распределённая блокировка остаются внешней приёмкой. PEM/private key import/export не реализованы; JSON metadata snapshot не обязателен для принятого объёма. +- Backups API использует общие Zod-контракты: список без дублей, обязательное storage, корректные даты/размеры; job counters и результаты согласованы, terminal имеет время окончания, чужой ID не принимается. Partial done с failures остаётся частичным результатом. Удалён неиспользуемый createBackups, ожидавший синхронные created вместо фактического job ack. +- Настройки и ответы restore/test/sync проверяются, запросы принимают AbortSignal и 30s срок. DELETE требует фактический HTTP 204, IDs кодируются. Некорректный запрос создания/расписания отклоняется до HTTP; известные false/0/empty не подменяются. +- 6 API fault unit, frontend TypeScript/профильный ESLint, backend и frontend production build прошли. Backups UI/операции ещё не приняты: Promise.all, отсутствие source/mode/RBAC guard, сброс черновика, повтор POST, интервал polling и ошибки событий требуют следующих блоков. Реальные PostgreSQL/RouterOS/S3 не проверялись. +- **Следующий блок 11А.25:** независимые подтверждённые каталоги backups/servers/schedule/storage, source guard и read/write права, initial/empty/stale/partial состояния. Далее формы настроек, создание/опрос, restore/delete/download и визуальная приёмка. diff --git a/packages/contracts/src/backups.ts b/packages/contracts/src/backups.ts index fc5edf9d..26a0c9a8 100644 --- a/packages/contracts/src/backups.ts +++ b/packages/contracts/src/backups.ts @@ -63,12 +63,12 @@ export const putBackupStorageSettingsSchema = z.object({ }) export const backupItemDtoSchema = z.object({ - id: z.string(), - serverId: z.number().nullable(), + id: z.string().min(1), + serverId: z.number().int().positive().safe().nullable(), serverName: z.string(), - filename: z.string(), - sizeBytes: z.number(), - createdAt: z.string(), + filename: z.string().min(1), + sizeBytes: z.number().int().nonnegative().safe(), + createdAt: z.string().datetime({ offset: true }), kind: z.enum(["manual", "auto"]), notes: z.string().optional(), storage: backupObjectStorageSchema, @@ -79,3 +79,40 @@ export const backupItemDtoSchema = z.object({ export type BackupStorageSettingsDto = z.infer export type PutBackupStorageSettings = z.infer export type BackupItemDto = z.infer + +const backupServerIdSchema = z.union([ + z.string().regex(/^[1-9][0-9]*$/).refine(value => Number.isSafeInteger(Number(value))), + z.number().int().positive().safe(), +]) +export const createBackupRequestSchema = z.object({ + serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"), + notes: z.string().max(500).optional(), +}) +export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"]) +const backupProgressSchema = z.object({ + status: backupJobStatusSchema, + total: z.number().int().positive().safe(), + completed: z.number().int().nonnegative().safe(), +}) +export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1) }).superRefine((job, ctx) => { + if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) +}) +export const backupJobResponseSchema = backupProgressSchema.extend({ + id: z.string().min(1), + requestedAt: z.string().datetime({ offset: true }), + startedAt: z.string().datetime({ offset: true }).optional(), + finishedAt: z.string().datetime({ offset: true }).optional(), + created: z.array(backupItemDtoSchema), + failures: z.array(z.object({ serverId: z.string().min(1), error: z.string().min(1) })), +}).superRefine((job, ctx) => { + if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" }) + if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" }) + const deviceFailures = job.failures.filter(failure => failure.serverId !== "job") + if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" }) + if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" }) +}) +export const backupsListResponseSchema = z.array(backupItemDtoSchema).refine(items => new Set(items.map(item => item.id)).size === items.length, "Дубли резервных копий") +export const restoreBackupResponseSchema = z.object({ filename: z.string().min(1), serverName: z.string().min(1) }) +export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() }) +export type CreateBackupJobResponse = z.infer +export type BackupJobStatusResponse = z.infer diff --git a/shared/api/backups.test.ts b/shared/api/backups.test.ts new file mode 100644 index 00000000..e9cc5ffb --- /dev/null +++ b/shared/api/backups.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { listBackups, createBackupsAsync, getBackupJob, deleteBackup, restoreBackup, getBackupScheduleSettings, putBackupScheduleSettings, getBackupStorageSettings, putBackupStorageSettings, testBackupStorage, syncBackupsFromStorage } from "./backups" + +const base = "https://api.example.test" +const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" } +const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] } +const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false } +const job = { id: "job-1", status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] } +function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) } + +test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json([]); assert.deepEqual(await listBackups(base), []) + globalThis.fetch = async () => json([item]); assert.equal((await listBackups(base))[0].sizeBytes, 0) + for (const payload of [{}, [item, item], [{ ...item, sizeBytes: -1 }], [{ ...item, serverId: "1" }], [{ ...item, createdAt: "bad" }], [{ ...item, storage: undefined }]]) { + globalThis.fetch = async () => json(payload); await assert.rejects(() => listBackups(base)) + } + } finally { globalThis.fetch = original } +}) +test("settings require a confirmed response and preserve false rather than defaults", async () => { + const original = globalThis.fetch + try { + for (const read of [getBackupScheduleSettings, getBackupStorageSettings]) { + globalThis.fetch = async () => json({}); await assert.rejects(() => read(base)) + globalThis.fetch = async () => new Response(null, { status: 204 }); await assert.rejects(() => read(base)) + } + globalThis.fetch = async url => json(String(url).endsWith("schedule") ? schedule : storage) + assert.equal((await getBackupScheduleSettings(base)).enabled, false) + assert.equal((await getBackupStorageSettings(base)).secretConfigured, false) + assert.equal((await putBackupScheduleSettings(base, { enabled: false })).enabled, false) + assert.equal((await putBackupStorageSettings(base, { keepLocalCopy: false })).keepLocalCopy, false) + } finally { globalThis.fetch = original } +}) +test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => { + const original = globalThis.fetch; let calls = 0, body: unknown + globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", status: "queued", total: 1, completed: 0 }, 202) } + try { + await assert.rejects(() => createBackupsAsync(base, { serverIds: [] })) + await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] })) + await assert.rejects(() => createBackupsAsync(base, { serverIds: ["0"] })) + await assert.rejects(() => putBackupScheduleSettings(base, { hour: 24 })) + assert.equal(calls, 0) + assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1") + assert.deepEqual(body, { serverIds: ["1"], notes: "manual" }) + globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] })) + } finally { globalThis.fetch = original } +}) +test("partial completion is valid, foreign and inconsistent job observations rejected", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json(job) + assert.equal((await getBackupJob(base, "job-1")).failures[0].error, "RouterOS unavailable") + await assert.rejects(() => getBackupJob(base, "job-other"), /другой задаче/) + for (const patch of [{ completed: 3 }, { completed: 1 }, { finishedAt: undefined }, { created: [item, item] }, { failures: [] }]) { + globalThis.fetch = async () => json({ ...job, ...patch }); await assert.rejects(() => getBackupJob(base, "job-1")) + } + globalThis.fetch = async () => json({ ...job, status: "failed", completed: 1, failures: [{ serverId: "job", error: "interrupted" }] }) + assert.equal((await getBackupJob(base, "job-1")).status, "failed") + } finally { globalThis.fetch = original } +}) +test("delete requires 204, encodes ID and carries caller cancellation", async () => { + const original = globalThis.fetch; let url = "", signal: AbortSignal | null | undefined + try { + globalThis.fetch = async (input, init) => { url = String(input); signal = init?.signal; return json({ ok: true }) } + await assert.rejects(() => deleteBackup(base, "a/b"), /204/); assert.ok(url.endsWith("a%2Fb")) + globalThis.fetch = async (_input, init) => { signal = init?.signal; return new Response(null, { status: 204 }) } + const abort = new AbortController(); await deleteBackup(base, "backup-1", { signal: abort.signal }); abort.abort(); assert.equal(signal?.aborted, true) + globalThis.fetch = async () => json({ error: "Нет доступа" }, 403); await assert.rejects(() => deleteBackup(base, "backup-1"), /Нет доступа/) + } finally { globalThis.fetch = original } +}) +test("restore, S3 test and sync do not accept empty or malformed success", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json({}); await assert.rejects(() => restoreBackup(base, "backup-1")); await assert.rejects(() => testBackupStorage(base)); await assert.rejects(() => syncBackupsFromStorage(base)) + globalThis.fetch = async () => json({ filename: "config.rsc", serverName: "router" }); assert.equal((await restoreBackup(base, "backup-1")).filename, "config.rsc") + globalThis.fetch = async () => json(storage); assert.equal((await testBackupStorage(base)).secretConfigured, false) + globalThis.fetch = async () => json({ imported: 0, skipped: 0 }); assert.equal((await syncBackupsFromStorage(base)).imported, 0) + globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base)) + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/backups.ts b/shared/api/backups.ts index 16b317e7..3593b21e 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -1,122 +1,63 @@ -import { requestJson } from "@/shared/api/http-client" -import type { - BackupScheduleSettingsDto, - BackupStorageSettingsDto, - PutBackupStorageSettings, +import { + backupJobResponseSchema, + backupScheduleSettingsDtoSchema, + backupStorageSettingsDtoSchema, + backupsListResponseSchema, + createBackupJobResponseSchema, + createBackupRequestSchema, + putBackupScheduleSettingsSchema, + putBackupStorageSettingsSchema, + restoreBackupResponseSchema, + syncBackupsResponseSchema, } from "@mmapp/contracts/backups" +import type { BackupItemDto, BackupScheduleSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups" +import { requestBlob, requestJson } from "@/shared/api/http-client" -export type BackupItem = { - id: string - serverId: number | string | null - serverName: string - filename: string - sizeBytes: number - createdAt: string - kind: "manual" | "auto" - notes?: string - storage?: "local" | "s3" | "both" - s3Key?: string | null - uploadError?: string | null -} +export type BackupItem = BackupItemDto +export type { CreateBackupJobResponse, BackupJobStatusResponse } from "@mmapp/contracts/backups" -type CreateBackupResponse = { - created: BackupItem[] - failures: Array<{ serverId: string; error: string }> +function backupOptions(options?: RequestInit): RequestInit { + return { ...options, signal: AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]) } } - -export type CreateBackupJobResponse = { - jobId: string - status: "queued" | "running" | "done" | "failed" - total: number - completed: number +function backupJson(baseUrl: string, path: string, options?: RequestInit) { + return requestJson(baseUrl, path, backupOptions(options)) } - -export type BackupJobStatusResponse = { - id: string - status: "queued" | "running" | "done" | "failed" - requestedAt: string - startedAt?: string - finishedAt?: string - total: number - completed: number - created: BackupItem[] - failures: Array<{ serverId: string; error: string }> +export async function listBackups(baseUrl: string, options?: RequestInit) { + return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" })) } - -export async function listBackups(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/backups") +export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) { + const body = createBackupRequestSchema.parse(payload) + return createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) })) } - -export async function createBackups( - baseUrl: string, - payload: { serverIds: string[]; notes?: string }, -): Promise { - return requestJson(baseUrl, "/api/backups/create", { - method: "POST", - body: JSON.stringify(payload), - }) +export async function getBackupJob(baseUrl: string, jobId: string, options?: RequestInit) { + const result = backupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/jobs/" + encodeURIComponent(jobId), { ...options, method: "GET" })) + if (result.id !== jobId) throw new Error("Ответ относится к другой задаче резервного копирования") + return result } - -export async function createBackupsAsync( - baseUrl: string, - payload: { serverIds: string[]; notes?: string }, -): Promise { - return requestJson(baseUrl, "/api/backups/create", { - method: "POST", - body: JSON.stringify(payload), - }) +export async function deleteBackup(baseUrl: string, id: string, options?: RequestInit): Promise { + const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id), backupOptions({ ...options, method: "DELETE" })) + if (response.status !== 204) throw new Error("Удаление резервной копии не подтверждено: ожидается HTTP 204") } - -export async function getBackupJob(baseUrl: string, jobId: string): Promise { - return requestJson(baseUrl, `/api/backups/jobs/${jobId}`) +export async function restoreBackup(baseUrl: string, id: string, options?: RequestInit) { + return restoreBackupResponseSchema.parse(await backupJson(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/restore", { ...options, method: "POST" })) } - -export async function deleteBackup(baseUrl: string, id: string): Promise { - await requestJson(baseUrl, `/api/backups/${id}`, { method: "DELETE" }) +export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) { + return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" })) } - -export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> { - return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, { - method: "POST", - }) +export async function putBackupScheduleSettings(baseUrl: string, payload: Partial, options?: RequestInit) { + const body = putBackupScheduleSettingsSchema.parse(payload) + return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) })) } - -export async function getBackupScheduleSettings(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/backups/schedule") +export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) { + return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" })) } - -export async function putBackupScheduleSettings( - baseUrl: string, - payload: Partial, -): Promise { - return requestJson(baseUrl, "/api/backups/schedule", { - method: "PUT", - body: JSON.stringify(payload), - }) +export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) { + const body = putBackupStorageSettingsSchema.parse(payload) + return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) })) } - -export async function getBackupStorageSettings(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/backups/storage") +export async function testBackupStorage(baseUrl: string, options?: RequestInit) { + return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" })) } - -export async function putBackupStorageSettings( - baseUrl: string, - payload: PutBackupStorageSettings, -): Promise { - return requestJson(baseUrl, "/api/backups/storage", { - method: "PUT", - body: JSON.stringify(payload), - }) -} - -export async function testBackupStorage(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/backups/storage/test", { method: "POST" }) -} - -export async function syncBackupsFromStorage( - baseUrl: string, -): Promise<{ imported: number; skipped: number }> { - return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", { - method: "POST", - }) +export async function syncBackupsFromStorage(baseUrl: string, options?: RequestInit) { + return syncBackupsResponseSchema.parse(await backupJson(baseUrl, "/api/backups/storage/sync", { ...options, method: "POST" })) }