From 9628310d53114787e6d0d6e1fe85af5224a0f799 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 29 Oct 2025 21:14:41 +0100 Subject: [PATCH] refactor(backups): use upsert instead of create/update split --- .../api-client/@tanstack/react-query.gen.ts | 26 +++++++ apps/client/app/api-client/sdk.gen.ts | 18 +++++ apps/client/app/api-client/types.gen.ts | 56 ++++++++++++++ .../app/modules/details/tabs/backups.tsx | 59 +++++---------- .../src/modules/backups/backups.controller.ts | 10 +++ .../server/src/modules/backups/backups.dto.ts | 36 +++++++++ .../src/modules/backups/backups.service.ts | 74 ++++++++++++++++++- 7 files changed, 236 insertions(+), 43 deletions(-) diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 52bcef0..513f51e 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -29,6 +29,7 @@ import { getBackupSchedule, updateBackupSchedule, getBackupScheduleForVolume, + upsertBackupSchedule, runBackupNow, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; @@ -75,6 +76,8 @@ import type { UpdateBackupScheduleData, UpdateBackupScheduleResponse, GetBackupScheduleForVolumeData, + UpsertBackupScheduleData, + UpsertBackupScheduleResponse, RunBackupNowData, RunBackupNowResponse, } from "../types.gen"; @@ -865,6 +868,29 @@ export const getBackupScheduleForVolumeOptions = (options: Options>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + UpsertBackupScheduleResponse, + DefaultError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await upsertBackupSchedule({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const runBackupNowQueryKey = (options: Options) => createQueryKey("runBackupNow", options); /** diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 205e7dd..9795d4d 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -62,6 +62,8 @@ import type { UpdateBackupScheduleResponses, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, + UpsertBackupScheduleData, + UpsertBackupScheduleResponses, RunBackupNowData, RunBackupNowResponses, } from "./types.gen"; @@ -426,6 +428,22 @@ export const getBackupScheduleForVolume = }); }; +/** + * Create or update a backup schedule for a volume + */ +export const upsertBackupSchedule = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).put({ + url: "/api/v1/backups/upsert", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); +}; + /** * Trigger a backup immediately for a schedule */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 7571aa7..c3839c0 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -1063,6 +1063,62 @@ export type GetBackupScheduleForVolumeResponses = { export type GetBackupScheduleForVolumeResponse = GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses]; +export type UpsertBackupScheduleData = { + body?: { + cronExpression: string; + enabled: boolean; + repositoryId: string; + volumeId: number; + excludePatterns?: Array; + includePatterns?: Array; + retentionPolicy?: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + }; + tags?: Array; + }; + path?: never; + query?: never; + url: "/api/v1/backups/upsert"; +}; + +export type UpsertBackupScheduleResponses = { + /** + * Backup schedule upserted successfully + */ + 200: { + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array | null; + id: number; + includePatterns: Array | null; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + }; +}; + +export type UpsertBackupScheduleResponse = UpsertBackupScheduleResponses[keyof UpsertBackupScheduleResponses]; + export type RunBackupNowData = { body?: never; path: { diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx index 502bb02..a6331d0 100644 --- a/apps/client/app/modules/details/tabs/backups.tsx +++ b/apps/client/app/modules/details/tabs/backups.tsx @@ -9,8 +9,7 @@ import { OnOff } from "~/components/onoff"; import type { Volume } from "~/lib/types"; import { listRepositoriesOptions, - createBackupScheduleMutation, - updateBackupScheduleMutation, + upsertBackupScheduleMutation, getBackupScheduleForVolumeOptions, } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/lib/errors"; @@ -76,27 +75,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { }; }, [existingSchedule, selectedRepository, volume.name]); - const createSchedule = useMutation({ - ...createBackupScheduleMutation(), + const upsertSchedule = useMutation({ + ...upsertBackupScheduleMutation(), onSuccess: () => { - toast.success("Backup schedule created successfully"); + toast.success("Backup schedule saved successfully"); queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + queryClient.invalidateQueries({ queryKey: ["getBackupScheduleForVolume", volume.id.toString()] }); }, onError: (error) => { - toast.error("Failed to create backup schedule", { - description: parseError(error)?.message, - }); - }, - }); - - const updateSchedule = useMutation({ - ...updateBackupScheduleMutation(), - onSuccess: () => { - toast.success("Backup schedule updated successfully"); - queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); - }, - onError: (error) => { - toast.error("Failed to update backup schedule", { + toast.error("Failed to save backup schedule", { description: parseError(error)?.message, }); }, @@ -113,27 +100,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly; if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly; - if (existingSchedule) { - updateSchedule.mutate({ - path: { scheduleId: existingSchedule.id.toString() }, - body: { - repositoryId: formValues.repositoryId, - enabled: isEnabled, - cronExpression, - retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, - }, - }); - } else { - createSchedule.mutate({ - body: { - volumeId: volume.id, - repositoryId: formValues.repositoryId, - enabled: true, - cronExpression, - retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, - }, - }); - } + upsertSchedule.mutate({ + body: { + volumeId: volume.id, + repositoryId: formValues.repositoryId, + enabled: existingSchedule ? isEnabled : true, + cronExpression, + retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + }, + }); }; if (loadingRepositories || loadingSchedules) { @@ -180,9 +155,9 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { if (!existingSchedule) return; setIsEnabled(enabled); - updateSchedule.mutate({ - path: { scheduleId: existingSchedule.id.toString() }, + upsertSchedule.mutate({ body: { + volumeId: existingSchedule.volumeId, repositoryId: existingSchedule.repositoryId, enabled, cronExpression: existingSchedule.cronExpression, diff --git a/apps/server/src/modules/backups/backups.controller.ts b/apps/server/src/modules/backups/backups.controller.ts index 8868e10..fbd2e7e 100644 --- a/apps/server/src/modules/backups/backups.controller.ts +++ b/apps/server/src/modules/backups/backups.controller.ts @@ -10,6 +10,8 @@ import { runBackupNowDto, updateBackupScheduleBody, updateBackupScheduleDto, + upsertBackupScheduleBody, + upsertBackupScheduleDto, type CreateBackupScheduleDto, type DeleteBackupScheduleDto, type GetBackupScheduleDto, @@ -17,6 +19,7 @@ import { type ListBackupSchedulesResponseDto, type RunBackupNowDto, type UpdateBackupScheduleDto, + type UpsertBackupScheduleDto, } from "./backups.dto"; import { backupsService } from "./backups.service"; @@ -54,6 +57,13 @@ export const backupScheduleController = new Hono() return c.json(schedule, 200); }) + .put("/upsert", upsertBackupScheduleDto, validator("json", upsertBackupScheduleBody), async (c) => { + const body = c.req.valid("json"); + + const schedule = await backupsService.upsertSchedule(body); + + return c.json(schedule, 200); + }) .delete("/:scheduleId", deleteBackupScheduleDto, async (c) => { const scheduleId = c.req.param("scheduleId"); diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts index 358dc0d..33fda1e 100644 --- a/apps/server/src/modules/backups/backups.dto.ts +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -169,6 +169,42 @@ export const updateBackupScheduleDto = describeRoute({ }, }); +/** + * Upsert a backup schedule (create or update) + */ +export const upsertBackupScheduleBody = type({ + volumeId: "number", + repositoryId: "string", + enabled: "boolean", + cronExpression: "string", + retentionPolicy: retentionPolicySchema.optional(), + excludePatterns: "string[]?", + includePatterns: "string[]?", + tags: "string[]?", +}); + +export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer; + +export const upsertBackupScheduleResponse = backupScheduleSchema; + +export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer; + +export const upsertBackupScheduleDto = describeRoute({ + description: "Create or update a backup schedule for a volume", + operationId: "upsertBackupSchedule", + tags: ["Backups"], + responses: { + 200: { + description: "Backup schedule upserted successfully", + content: { + "application/json": { + schema: resolver(upsertBackupScheduleResponse), + }, + }, + }, + }, +}); + /** * Delete a backup schedule */ diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 617cc62..b1b6ae2 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -7,7 +7,7 @@ import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/ import { restic } from "../../utils/restic"; import { logger } from "../../utils/logger"; import { getVolumePath } from "../volumes/helpers"; -import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto"; +import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpsertBackupScheduleBody } from "./backups.dto"; import { toMessage } from "../../utils/errors"; const calculateNextRun = (cronExpression: string): number => { @@ -255,6 +255,77 @@ const getScheduleForVolume = async (volumeId: number) => { return schedule ?? null; }; +const upsertSchedule = async (data: UpsertBackupScheduleBody) => { + if (!cron.validate(data.cronExpression)) { + throw new BadRequestError("Invalid cron expression"); + } + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.id, data.volumeId), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, data.repositoryId), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + const existingSchedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.volumeId, data.volumeId), + }); + + const nextBackupAt = calculateNextRun(data.cronExpression); + + if (existingSchedule) { + const [updated] = await db + .update(backupSchedulesTable) + .set({ + repositoryId: data.repositoryId, + enabled: data.enabled, + cronExpression: data.cronExpression, + retentionPolicy: data.retentionPolicy ?? null, + excludePatterns: data.excludePatterns ?? [], + includePatterns: data.includePatterns ?? [], + nextBackupAt: nextBackupAt, + updatedAt: Date.now(), + }) + .where(eq(backupSchedulesTable.id, existingSchedule.id)) + .returning(); + + if (!updated) { + throw new Error("Failed to update backup schedule"); + } + + return updated; + } + + const [newSchedule] = await db + .insert(backupSchedulesTable) + .values({ + volumeId: data.volumeId, + repositoryId: data.repositoryId, + enabled: data.enabled, + cronExpression: data.cronExpression, + retentionPolicy: data.retentionPolicy ?? null, + excludePatterns: data.excludePatterns ?? [], + includePatterns: data.includePatterns ?? [], + nextBackupAt: nextBackupAt, + }) + .returning(); + + if (!newSchedule) { + throw new Error("Failed to create backup schedule"); + } + + return newSchedule; +}; + export const backupsService = { listSchedules, getSchedule, @@ -264,4 +335,5 @@ export const backupsService = { executeBackup, getSchedulesToExecute, getScheduleForVolume, + upsertSchedule, };