Skip to content

Commit 54a892d

Browse files
committed
feat: backup + restore
1 parent 61da617 commit 54a892d

File tree

11 files changed

+340
-63
lines changed

11 files changed

+340
-63
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ yarn-error.log*
4545
*.tsbuildinfo
4646
next-env.d.ts
4747

48-
# sqlite
48+
# databases
49+
pgdata
4950
*.db
5051
*.sqlite
5152
*.sqlite3

app/export/transactions/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ExportFields, ExportFilters, exportImportFieldsMapping } from "@/models/export_and_import"
1+
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
22
import { getFields } from "@/models/fields"
33
import { getFilesByTransactionId } from "@/models/files"
44
import { getTransactions } from "@/models/transactions"
@@ -38,7 +38,7 @@ export async function GET(request: Request) {
3838
const row: Record<string, any> = {}
3939
for (const key of fieldKeys) {
4040
const value = transaction[key as keyof typeof transaction] ?? ""
41-
const exportFieldSettings = exportImportFieldsMapping[key]
41+
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[key]
4242
if (exportFieldSettings && exportFieldSettings.export) {
4343
row[key] = await exportFieldSettings.export(value)
4444
} else {

app/import/csv/actions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use server"
22

3-
import { exportImportFieldsMapping } from "@/models/export_and_import"
3+
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
44
import { createTransaction } from "@/models/transactions"
55
import { parse } from "@fast-csv/parse"
66
import { revalidatePath } from "next/cache"
@@ -44,7 +44,7 @@ export async function saveTransactionsAction(prevState: any, formData: FormData)
4444
for (const row of rows) {
4545
const transactionData: Record<string, unknown> = {}
4646
for (const [fieldCode, value] of Object.entries(row)) {
47-
const fieldDef = exportImportFieldsMapping[fieldCode]
47+
const fieldDef = EXPORT_AND_IMPORT_FIELD_MAP[fieldCode]
4848
if (fieldDef?.import) {
4949
transactionData[fieldCode] = await fieldDef.import(value as string)
5050
} else {

app/settings/backups/actions.ts

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,214 @@
11
"use server"
22

3-
import { DATABASE_FILE } from "@/lib/db"
3+
import { prisma } from "@/lib/db"
4+
import { FILE_UPLOAD_PATH } from "@/lib/files"
5+
import { MODEL_BACKUP } from "@/models/backups"
46
import fs from "fs"
7+
import { mkdir } from "fs/promises"
8+
import JSZip from "jszip"
9+
import path from "path"
10+
11+
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
512

613
export async function restoreBackupAction(prevState: any, formData: FormData) {
714
const file = formData.get("file") as File
15+
const removeExistingData = formData.get("removeExistingData") === "true"
16+
817
if (!file) {
918
return { success: false, error: "No file provided" }
1019
}
1120

21+
// Restore tables
1222
try {
1323
const fileBuffer = await file.arrayBuffer()
1424
const fileData = Buffer.from(fileBuffer)
15-
fs.writeFileSync(DATABASE_FILE, fileData)
25+
const zip = await JSZip.loadAsync(fileData)
26+
27+
// Check backup version
28+
const metadataFile = zip.file("data/metadata.json")
29+
if (metadataFile) {
30+
const metadataContent = await metadataFile.async("string")
31+
try {
32+
const metadata = JSON.parse(metadataContent)
33+
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
34+
return {
35+
success: false,
36+
error: `Incompatible backup version: ${
37+
metadata.version || "unknown"
38+
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
39+
}
40+
}
41+
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
42+
} catch (error) {
43+
console.warn("Could not parse backup metadata:", error)
44+
}
45+
} else {
46+
console.warn("No metadata found in backup, assuming legacy format")
47+
}
48+
49+
if (removeExistingData) {
50+
await clearAllTables()
51+
}
52+
53+
for (const { filename, model, idField } of MODEL_BACKUP) {
54+
try {
55+
const jsonFile = zip.file(`data/${filename}`)
56+
if (jsonFile) {
57+
const jsonContent = await jsonFile.async("string")
58+
const restoredCount = await restoreModelFromJSON(model, jsonContent, idField)
59+
console.log(`Restored ${restoredCount} records from ${filename}`)
60+
}
61+
} catch (error) {
62+
console.error(`Error restoring model from ${filename}:`, error)
63+
}
64+
}
65+
66+
// Restore files
67+
try {
68+
const filesToRestore = Object.keys(zip.files).filter(
69+
(filename) => filename.startsWith("data/uploads/") && !filename.endsWith("/")
70+
)
71+
72+
if (filesToRestore.length > 0) {
73+
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
74+
75+
// Extract and save each file
76+
let restoredFilesCount = 0
77+
for (const zipFilePath of filesToRestore) {
78+
const file = zip.file(zipFilePath)
79+
if (file) {
80+
const relativeFilePath = zipFilePath.replace("data/uploads/", "")
81+
const fileContent = await file.async("nodebuffer")
82+
83+
const filePath = path.join(FILE_UPLOAD_PATH, relativeFilePath)
84+
const fileName = path.basename(filePath)
85+
const fileId = path.basename(fileName, path.extname(fileName))
86+
const fileDir = path.dirname(filePath)
87+
await mkdir(fileDir, { recursive: true })
88+
89+
// Write the file
90+
fs.writeFileSync(filePath, fileContent)
91+
restoredFilesCount++
92+
93+
// Update the file record
94+
await prisma.file.upsert({
95+
where: { id: fileId },
96+
update: {
97+
path: filePath,
98+
},
99+
create: {
100+
id: relativeFilePath,
101+
path: filePath,
102+
filename: fileName,
103+
mimetype: "application/octet-stream",
104+
},
105+
})
106+
}
107+
}
108+
}
109+
} catch (error) {
110+
console.error("Error restoring uploaded files:", error)
111+
return {
112+
success: false,
113+
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
114+
}
115+
}
116+
117+
return { success: true, message: `Restore completed successfully` }
118+
} catch (error) {
119+
console.error("Error restoring from backup:", error)
120+
return {
121+
success: false,
122+
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
123+
}
124+
}
125+
}
126+
127+
async function clearAllTables() {
128+
// Delete in reverse order to handle foreign key constraints
129+
for (const { model } of [...MODEL_BACKUP].reverse()) {
130+
try {
131+
await model.deleteMany({})
132+
} catch (error) {
133+
console.error(`Error clearing table:`, error)
134+
}
135+
}
136+
}
137+
138+
async function restoreModelFromJSON(model: any, jsonContent: string, idField: string): Promise<number> {
139+
if (!jsonContent) return 0
140+
141+
try {
142+
const records = JSON.parse(jsonContent)
143+
144+
if (!records || records.length === 0) {
145+
return 0
146+
}
147+
148+
let insertedCount = 0
149+
for (const rawRecord of records) {
150+
const record = processRowData(rawRecord)
151+
152+
try {
153+
// Skip records that don't have the required ID field
154+
if (record[idField] === undefined) {
155+
console.warn(`Skipping record missing required ID field '${idField}'`)
156+
continue
157+
}
158+
159+
await model.upsert({
160+
where: { [idField]: record[idField] },
161+
update: record,
162+
create: record,
163+
})
164+
insertedCount++
165+
} catch (error) {
166+
console.error(`Error upserting record:`, error)
167+
}
168+
}
169+
170+
return insertedCount
16171
} catch (error) {
17-
return { success: false, error: "Failed to restore backup" }
172+
console.error(`Error parsing JSON content:`, error)
173+
return 0
174+
}
175+
}
176+
177+
function processRowData(row: Record<string, any>): Record<string, any> {
178+
const processedRow: Record<string, any> = {}
179+
180+
for (const [key, value] of Object.entries(row)) {
181+
if (value === "" || value === "null" || value === undefined) {
182+
processedRow[key] = null
183+
continue
184+
}
185+
186+
// Try to parse JSON for object fields
187+
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) {
188+
try {
189+
processedRow[key] = JSON.parse(value)
190+
continue
191+
} catch (e) {
192+
// Not valid JSON, continue with normal processing
193+
}
194+
}
195+
196+
// Handle dates (checking for ISO date format)
197+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(value)) {
198+
processedRow[key] = new Date(value)
199+
continue
200+
}
201+
202+
// Handle numbers
203+
if (typeof value === "string" && !isNaN(Number(value)) && key !== "id" && !key.endsWith("Code")) {
204+
// Convert numbers but preserving string IDs
205+
processedRow[key] = Number(value)
206+
continue
207+
}
208+
209+
// Default: keep as is
210+
processedRow[key] = value
18211
}
19212

20-
return { success: true }
213+
return processedRow
21214
}

app/settings/backups/data/route.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { DATABASE_FILE } from "@/lib/db"
21
import { FILE_UPLOAD_PATH } from "@/lib/files"
2+
import { MODEL_BACKUP } from "@/models/backups"
33
import fs, { readdirSync } from "fs"
44
import JSZip from "jszip"
55
import { NextResponse } from "next/server"
66
import path from "path"
77

8+
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
9+
const BACKUP_VERSION = "1.0"
10+
811
export async function GET(request: Request) {
912
try {
1013
const zip = new JSZip()
@@ -14,8 +17,29 @@ export async function GET(request: Request) {
1417
return new NextResponse("Internal Server Error", { status: 500 })
1518
}
1619

17-
const databaseFile = fs.readFileSync(DATABASE_FILE)
18-
rootFolder.file("database.sqlite", databaseFile)
20+
// Add metadata with version information
21+
rootFolder.file(
22+
"metadata.json",
23+
JSON.stringify(
24+
{
25+
version: BACKUP_VERSION,
26+
timestamp: new Date().toISOString(),
27+
models: MODEL_BACKUP.map((m) => m.filename),
28+
},
29+
null,
30+
2
31+
)
32+
)
33+
34+
// Backup models
35+
for (const { filename, model } of MODEL_BACKUP) {
36+
try {
37+
const jsonContent = await tableToJSON(model)
38+
rootFolder.file(filename, jsonContent)
39+
} catch (error) {
40+
console.error(`Error exporting table ${filename}:`, error)
41+
}
42+
}
1943

2044
const uploadsFolder = rootFolder.folder("uploads")
2145
if (!uploadsFolder) {
@@ -25,7 +49,23 @@ export async function GET(request: Request) {
2549

2650
const uploadedFiles = getAllFilePaths(FILE_UPLOAD_PATH)
2751
uploadedFiles.forEach((file) => {
28-
uploadsFolder.file(file.replace(FILE_UPLOAD_PATH, ""), fs.readFileSync(file))
52+
try {
53+
// Check file size before reading
54+
const stats = fs.statSync(file)
55+
if (stats.size > MAX_FILE_SIZE) {
56+
console.warn(
57+
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
58+
MAX_FILE_SIZE / 1024 / 1024
59+
}MB limit)`
60+
)
61+
return
62+
}
63+
64+
const fileContent = fs.readFileSync(file)
65+
uploadsFolder.file(file.replace(FILE_UPLOAD_PATH, ""), fileContent)
66+
} catch (error) {
67+
console.error(`Error reading file ${file}:`, error)
68+
}
2969
})
3070
const archive = await zip.generateAsync({ type: "blob" })
3171

@@ -60,3 +100,13 @@ function getAllFilePaths(dirPath: string): string[] {
60100
readDirectory(dirPath)
61101
return filePaths
62102
}
103+
104+
async function tableToJSON(model: any): Promise<string> {
105+
const data = await model.findMany()
106+
107+
if (!data || data.length === 0) {
108+
return "[]"
109+
}
110+
111+
return JSON.stringify(data, null, 2)
112+
}

0 commit comments

Comments
 (0)