diff --git a/src/features/database/actions/bulk-restore.action.ts b/src/features/database/actions/bulk-restore.action.ts new file mode 100644 index 00000000..904b84d6 --- /dev/null +++ b/src/features/database/actions/bulk-restore.action.ts @@ -0,0 +1,152 @@ +"use server"; + +import {z} from "zod"; +import {and, desc, eq, isNull} from "drizzle-orm"; +import {db} from "@/db"; +import * as drizzleDb from "@/db"; +import {ServerActionResult} from "@/types/action-type"; +import {userAction, ActionError} from "@/lib/safe-actions/actions"; +import {getOrganization} from "@/lib/auth/auth"; + +// Throws if the project isn't in the active org, or any db isn't in the project. +export async function assertDatabasesInOrgProject(projectId: string, databaseIds: string[]) { + const organization = await getOrganization({}); + if (!organization) throw new ActionError("No active organization."); + + const project = await db.query.project.findFirst({ + where: and( + eq(drizzleDb.schemas.project.id, projectId), + eq(drizzleDb.schemas.project.organizationId, organization.id), + ), + with: {databases: true}, + }); + if (!project) throw new ActionError("Project not found."); + + const allowed = new Set(project.databases.map((d) => d.id)); + for (const id of databaseIds) { + if (!allowed.has(id)) throw new ActionError("Database not in project."); + } +} + +export type RestorePreviewRow = { + databaseId: string; + name: string; + backupId?: string; + backupStorageId?: string; + backupDate?: string; // ISO + restorable: boolean; +}; + +// For each db, latest non-deleted backup whose latest non-deleted backupStorage has status "success". +async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise { + const project = await db.query.project.findFirst({ + where: eq(drizzleDb.schemas.project.id, projectId), + with: {databases: true}, + }); + const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const)); + + const rows: RestorePreviewRow[] = []; + for (const databaseId of databaseIds) { + const backups = await db.query.backup.findMany({ + where: and( + eq(drizzleDb.schemas.backup.databaseId, databaseId), + isNull(drizzleDb.schemas.backup.deletedAt), + ), + orderBy: desc(drizzleDb.schemas.backup.createdAt), + }); + + let picked: RestorePreviewRow | undefined; + for (const backup of backups) { + const storage = await db.query.backupStorage.findFirst({ + where: and( + eq(drizzleDb.schemas.backupStorage.backupId, backup.id), + eq(drizzleDb.schemas.backupStorage.status, "success"), + isNull(drizzleDb.schemas.backupStorage.deletedAt), + ), + orderBy: desc(drizzleDb.schemas.backupStorage.createdAt), + }); + if (storage) { + picked = { + databaseId, + name: nameById.get(databaseId) ?? databaseId, + backupId: backup.id, + backupStorageId: storage.id, + backupDate: backup.createdAt?.toISOString(), + restorable: true, + }; + break; + } + } + + rows.push(picked ?? {databaseId, name: nameById.get(databaseId) ?? databaseId, restorable: false}); + } + return rows; +} + +const bulkSchema = z.object({ + projectId: z.string().uuid(), + databaseIds: z.array(z.string().uuid()).min(1), +}); + +export const bulkRestorePreviewAction = userAction + .schema(bulkSchema) + .action(async ({parsedInput}): Promise> => { + try { + const {projectId, databaseIds} = parsedInput; + await assertDatabasesInOrgProject(projectId, databaseIds); + const rows = await resolveLatestRestorable(projectId, databaseIds); + return {success: true, value: rows}; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to build restore preview.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }); + +export const bulkRestoreLatestAction = userAction + .schema(bulkSchema) + .action(async ({parsedInput}): Promise> => { + try { + const {projectId, databaseIds} = parsedInput; + await assertDatabasesInOrgProject(projectId, databaseIds); + const rows = await resolveLatestRestorable(projectId, databaseIds); + + const restorable = rows.filter((r) => r.restorable); + const skipped = rows + .filter((r) => !r.restorable) + .map((r) => ({databaseId: r.databaseId, reason: "no successful backup"})); + + if (restorable.length > 0) { + await db.insert(drizzleDb.schemas.restoration).values( + restorable.map((r) => ({ + databaseId: r.databaseId, + backupId: r.backupId!, + backupStorageId: r.backupStorageId!, + status: "waiting" as const, + })), + ); + } + + return { + success: true, + value: {queued: restorable.length, skipped}, + actionSuccess: { + message: `Queued ${restorable.length} restore(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`, + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to queue restores.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + });