feat: backup delete/restore actions.

This commit is contained in:
charlesgauthereau
2026-01-17 19:23:15 +01:00
parent 7d818206b0
commit 83ef0ee74e
7 changed files with 371 additions and 141 deletions
-34
View File
@@ -129,41 +129,7 @@ export async function POST(
const fileName = `${uuid}${fileExtension}`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
// if (!settings) {
// throw new Error("System settings not found.");
// }
// let success: boolean, message: string, filePath: string;
//
// const result =
// settings.storage === "local"
// ? await uploadLocalPrivate(fileName, buffer)
// : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
//
// ({success, message, filePath} = result);
//
// if (!success) {
// return NextResponse.json(
// {error: message},
// {status: 500}
// );
// }
await storeBackupFiles(backup, database, buffer, fileName) await storeBackupFiles(backup, database, buffer, fileName)
//
// await db
// .update(drizzleDb.schemas.backup)
// .set(withUpdatedAt({
// file: fileName,
// fileSize: fileSizeBytes,
// status: 'success',
// }))
// .where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup"); await sendNotificationsBackupRestore(database, "success_backup");
+1 -1
View File
@@ -131,7 +131,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
restoreAction = false restoreAction = false
return; continue;
} }
@@ -1,11 +1,9 @@
"use client" "use client"
import {BackupWith, Restoration} from "@/db/schema/07_database"; import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
import React, {useState} from "react"; import React from "react";
import {Swiper, SwiperSlide} from "swiper/react"; import {Swiper, SwiperSlide} from "swiper/react";
import "swiper/css"; import "swiper/css";
import "swiper/css/pagination"; import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules"; import {Pagination, Mousewheel} from "swiper/modules";
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
@@ -23,7 +21,7 @@ import {useIsMobile} from "@/hooks/use-mobile";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import { import {
createRestorationBackupAction, createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction downloadBackupAction
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {toast} from "sonner"; import {toast} from "sonner";
@@ -37,6 +35,7 @@ type BackupActionsFormProps = {
} }
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const {closeModal} = useBackupModal(); const {closeModal} = useBackupModal();
@@ -49,8 +48,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
let result: SafeActionResult<string, ZodString, readonly [], { let result: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined; _errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration>, object> | undefined }, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
if (action === "download") { if (action === "download") {
result = await downloadBackupAction({backupStorageId: values.backupStorageId}) result = await downloadBackupAction({backupStorageId: values.backupStorageId})
@@ -60,13 +58,18 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
backupStorageId: values.backupStorageId, backupStorageId: values.backupStorageId,
backupId: backup.id backupId: backup.id
}) })
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
})
} }
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
if (action === "download") { if (action === "download") {
console.log(inner.value) console.log(inner.value)
const url = inner.value const url = inner.value
@@ -76,25 +79,45 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
closeModal() closeModal()
} else if (action === "restore") { } else if (action === "restore") {
closeModal() closeModal()
} else if (action === "delete") {
closeModal()
} else {
closeModal()
} }
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
closeModal()
} else { } else {
toast.error(inner?.actionError?.message); toast.error(inner?.actionError?.message);
} }
}
}, },
}); });
const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
console.log("mutation deleteEntireBackup");
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
closeModal()
} else {
toast.error(inner?.actionError?.message);
}
},
});
return ( return (
<TooltipProvider> <TooltipProvider>
{action == "delete" ?
<>
</>
:
<Form <Form
form={form} form={form}
className="flex flex-col gap-4 mb-1" className="flex flex-col gap-4 mb-1"
@@ -120,16 +143,17 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
className="mySwiper" className="mySwiper"
style={{height: "100%"}} style={{height: "100%"}}
> >
{backup.storages?.map((storage: BackupStorageWith) => ( {backup.storages?.filter((storage) => storage.deletedAt === null).map((storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}> <SwiperSlide key={storage.id}>
<button <button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
type="button" type="button"
onClick={() => field.onChange(storage.id)} onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors ${ className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
field.value === storage.id ${field.value === storage.id
? "border-foreground bg-background" ? "border-foreground bg-background"
: "border-border bg-background hover:border-muted-foreground" : "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
}`} ${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
> >
<div <div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${ className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
@@ -145,7 +169,9 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getChannelIcon(storage.storageChannel?.provider || "")} {getChannelIcon(storage.storageChannel?.provider || "")}
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}</h3> <h3 className="font-medium text-foreground">
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}
</h3>
<Badge variant="secondary" <Badge variant="secondary"
className="text-xs font-mono"> className="text-xs font-mono">
{storage.storageChannel?.provider} {storage.storageChannel?.provider}
@@ -163,7 +189,6 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
</div> </div>
</button> </button>
</SwiperSlide> </SwiperSlide>
)) ?? <p>No storages available</p>} )) ?? <p>No storages available</p>}
</Swiper> </Swiper>
</div> </div>
@@ -172,14 +197,29 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-row items-center gap-x-4 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending} {action === "delete" && (
disabled={mutation.isPending}> <ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
disabled={mutationDeleteEntireBackup.isPending}
>
Delete All
</ButtonWithLoading>
)}
<ButtonWithLoading
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm Confirm
</ButtonWithLoading> </ButtonWithLoading>
</div> </div>
</Form> </Form>
}
</TooltipProvider> </TooltipProvider>
); );
@@ -22,13 +22,14 @@ export const DatabaseBackupActionsModal = ({}: DatabaseActionsModalProps) => {
if (!backup || !action) return null; if (!backup || !action) return null;
const text = getBackupActionTextBasedOnActionKind(action); const text = getBackupActionTextBasedOnActionKind(action);
return ( return (
<Dialog open={open} onOpenChange={closeModal}> <Dialog open={open} onOpenChange={closeModal}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{text} storage backup ?</DialogTitle> <DialogTitle>{text} backup ?</DialogTitle>
<DialogDescription> <DialogDescription>
Select the backup storage you want to {text.toLowerCase()} Select the backup storage
</DialogDescription> </DialogDescription>
<Separator className="mt-3 mb-3"/> <Separator className="mt-3 mb-3"/>
</DialogHeader> </DialogHeader>
@@ -5,9 +5,10 @@ import {z} from "zod";
import type {StorageInput} from "@/features/storages/types"; import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch"; import {dispatchStorage} from "@/features/storages/dispatch";
import {db} from "@/db"; import {db} from "@/db";
import {eq} from "drizzle-orm"; import {and, eq, isNull, ne, sql} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {Restoration} from "@/db/schema/07_database"; import {Backup, Restoration} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
export const downloadBackupAction = userAction.schema( export const downloadBackupAction = userAction.schema(
@@ -120,3 +121,217 @@ export const createRestorationBackupAction = userAction
}; };
} }
}); });
export const deleteBackupStorageAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
backupStorageId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId, backupStorageId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const backupStorage = await db.query.backupStorage.findFirst({
where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
});
if (!backupStorage) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
};
}
const [{count}] = await db
.select({count: sql<number>`count(*)`})
.from(drizzleDb.schemas.backupStorage)
.where(
and(
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
isNull(drizzleDb.schemas.backupStorage.deletedAt),
ne(drizzleDb.schemas.backupStorage.id, backupStorageId)
)
);
if (Number(count) === 0) {
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
if (backupStorage.status != "success" || !backupStorage.path) {
return {
success: false,
actionError: {
message: "An error occurred.",
status: 500,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
export const deleteBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupId: backupId},
},
}
}
const backupStorages = await db.query.backupStorage.findMany({
where: eq(drizzleDb.schemas.backupStorage.backupId, backupId),
});
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: backupId},
},
};
}
for (const backupStorage of backupStorages) {
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id))
if (backupStorage.status != "success" || !backupStorage.path) {
continue;
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
@@ -2,6 +2,7 @@
import {createContext, useContext, useState, ReactNode} from "react"; import {createContext, useContext, useState, ReactNode} from "react";
import {BackupWith} from "@/db/schema/07_database"; import {BackupWith} from "@/db/schema/07_database";
import {useRouter} from "next/navigation";
export type DatabaseActionKind = "restore" | "download" | "delete"; export type DatabaseActionKind = "restore" | "download" | "delete";
@@ -31,6 +32,7 @@ const BackupModalContext = createContext<BackupModalContextType | undefined>(und
export const BackupModalProvider = ({children}: { children: ReactNode }) => { export const BackupModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const router = useRouter();
const [action, setAction] = useState<DatabaseActionKind | null>(null); const [action, setAction] = useState<DatabaseActionKind | null>(null);
const [backup, setBackup] = useState<BackupWith | null>(null); const [backup, setBackup] = useState<BackupWith | null>(null);
@@ -44,6 +46,7 @@ export const BackupModalProvider = ({children}: { children: ReactNode }) => {
setOpen(false); setOpen(false);
setAction(null); setAction(null);
setBackup(null); setBackup(null);
router.refresh()
}; };
return ( return (
@@ -9,10 +9,10 @@ import {useMemo, useState} from "react";
import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database"; import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {deleteBackupAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {MemberWithUser} from "@/db/schema/03_organization"; import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
type DatabaseBackupListProps = { type DatabaseBackupListProps = {
@@ -65,13 +65,18 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const results = await Promise.all( const results = await Promise.all(
backups.map(async (backup) => { backups.map(async (backup) => {
if (backup.deletedAt == null) { if (backup.deletedAt == null) {
const backupDeleted = await deleteBackupAction({ const backupDeleted = await deleteBackupAction({
backupId: backup.id,
databaseId: backup.databaseId, databaseId: backup.databaseId,
status: backup.status, backupId: backup.id,
file: backup.file ?? "", })
projectSlug: props.database?.project?.slug! // const backupDeleted = await deleteBackupAction({
}); // backupId: backup.id,
// databaseId: backup.databaseId,
// status: backup.status,
// file: backup.file ?? "",
// projectSlug: props.database?.project?.slug!
// });
return { return {
success: backupDeleted?.data?.success, success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success message: backupDeleted?.data?.success