plum-e2e 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ -- AlterTable Project: add S3 backup configuration fields
2
+ ALTER TABLE "Project" ADD COLUMN "backupEnabled" BOOLEAN NOT NULL DEFAULT false;
3
+ ALTER TABLE "Project" ADD COLUMN "backupCron" TEXT NOT NULL DEFAULT '0 2 * * *';
4
+ ALTER TABLE "Project" ADD COLUMN "backupS3Endpoint" TEXT NOT NULL DEFAULT '';
5
+ ALTER TABLE "Project" ADD COLUMN "backupS3Region" TEXT NOT NULL DEFAULT '';
6
+ ALTER TABLE "Project" ADD COLUMN "backupS3Bucket" TEXT NOT NULL DEFAULT '';
7
+ ALTER TABLE "Project" ADD COLUMN "backupS3AccessKey" TEXT NOT NULL DEFAULT '';
8
+ ALTER TABLE "Project" ADD COLUMN "backupS3SecretKey" TEXT NOT NULL DEFAULT '';
9
+ ALTER TABLE "Project" ADD COLUMN "backupS3Prefix" TEXT NOT NULL DEFAULT '';
10
+ ALTER TABLE "Project" ADD COLUMN "backupLastRunAt" TIMESTAMP(3);
11
+ ALTER TABLE "Project" ADD COLUMN "backupLastStatus" TEXT NOT NULL DEFAULT '';
@@ -70,16 +70,26 @@ model Report {
70
70
  }
71
71
 
72
72
  model Project {
73
- id Int @id @default(autoincrement())
74
- name String @default("")
75
- logoUrl String @default("")
76
- testCasePrefix String @default("TC")
77
- testSuitePrefix String @default("TS")
78
- caseSeqNext Int @default(0)
79
- suiteSeqNext Int @default(0)
80
- discordWebhookUrl String @default("")
81
- slackWebhookUrl String @default("")
82
- notifyPublicUrl String @default("")
73
+ id Int @id @default(autoincrement())
74
+ name String @default("")
75
+ logoUrl String @default("")
76
+ testCasePrefix String @default("TC")
77
+ testSuitePrefix String @default("TS")
78
+ caseSeqNext Int @default(0)
79
+ suiteSeqNext Int @default(0)
80
+ discordWebhookUrl String @default("")
81
+ slackWebhookUrl String @default("")
82
+ notifyPublicUrl String @default("")
83
+ backupEnabled Boolean @default(false)
84
+ backupCron String @default("0 2 * * *")
85
+ backupS3Endpoint String @default("")
86
+ backupS3Region String @default("")
87
+ backupS3Bucket String @default("")
88
+ backupS3AccessKey String @default("")
89
+ backupS3SecretKey String @default("")
90
+ backupS3Prefix String @default("")
91
+ backupLastRunAt DateTime?
92
+ backupLastStatus String @default("")
83
93
  }
84
94
 
85
95
  model User {
@@ -18,7 +18,10 @@
18
18
  const express = require('express');
19
19
  const router = express.Router();
20
20
  const backupService = require('../services/backupService');
21
+ const settingsService = require('../services/settingsService');
21
22
  const cronService = require('../services/cronService');
23
+ const backupCronService = require('../services/backupCronService');
24
+ const prisma = require('../services/prisma');
22
25
 
23
26
  router.get('/export', async (req, res) => {
24
27
  try {
@@ -35,11 +38,15 @@ router.get('/export', async (req, res) => {
35
38
 
36
39
  router.post('/import', async (req, res) => {
37
40
  try {
38
- const { cronJobs, reports, project } = req.body;
39
- if (!Array.isArray(cronJobs) && !Array.isArray(reports) && !project) {
40
- return res.status(400).json({ error: 'Invalid backup format' });
41
- }
42
- await backupService.importAll({ cronJobs, reports, project }, cronService);
41
+ const { cronJobs, project, users, runners, testSuites, testRuns } = req.body;
42
+ const hasData = [cronJobs, project, users, runners, testSuites, testRuns].some(
43
+ (v) => v !== undefined && v !== null
44
+ );
45
+ if (!hasData) return res.status(400).json({ error: 'Invalid backup format' });
46
+ await backupService.importAll(
47
+ { cronJobs, project, users, runners, testSuites, testRuns },
48
+ cronService
49
+ );
43
50
  res.json({ message: 'Import successful' });
44
51
  } catch (error) {
45
52
  console.error('Import failed:', error);
@@ -47,4 +54,62 @@ router.post('/import', async (req, res) => {
47
54
  }
48
55
  });
49
56
 
57
+ router.get('/config', async (req, res) => {
58
+ try {
59
+ const config = await settingsService.getBackupConfig();
60
+ res.json(config);
61
+ } catch (error) {
62
+ console.error('Failed to get backup config:', error);
63
+ res.status(500).json({ error: 'Failed to get backup configuration' });
64
+ }
65
+ });
66
+
67
+ router.post('/config', async (req, res) => {
68
+ try {
69
+ await settingsService.updateBackupConfig(req.body);
70
+ await backupCronService.reload();
71
+ const config = await settingsService.getBackupConfig();
72
+ res.json(config);
73
+ } catch (error) {
74
+ console.error('Failed to save backup config:', error);
75
+ res.status(500).json({ error: 'Failed to save backup configuration' });
76
+ }
77
+ });
78
+
79
+ router.post('/test-s3', async (req, res) => {
80
+ try {
81
+ // If no secret key provided in the request, fall back to the stored one
82
+ let config = { ...req.body };
83
+ if (!config.backupS3SecretKey) {
84
+ const stored = await prisma.project.findUnique({ where: { id: 1 } });
85
+ config.backupS3SecretKey = stored?.backupS3SecretKey ?? '';
86
+ }
87
+
88
+ const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
89
+ const missing = required.filter((k) => !config[k]);
90
+ if (missing.length > 0) {
91
+ return res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` });
92
+ }
93
+
94
+ await backupService.testS3Connection(config);
95
+ res.json({ ok: true });
96
+ } catch (error) {
97
+ res.status(400).json({ error: error.message || 'Connection failed' });
98
+ }
99
+ });
100
+
101
+ router.post('/run-now', async (req, res) => {
102
+ try {
103
+ await backupCronService.runBackup();
104
+ const config = await settingsService.getBackupConfig();
105
+ if (config.backupLastStatus?.startsWith('error:')) {
106
+ return res.status(500).json({ error: config.backupLastStatus.replace('error:', '') });
107
+ }
108
+ res.json({ ok: true, lastRunAt: config.backupLastRunAt, lastStatus: config.backupLastStatus });
109
+ } catch (error) {
110
+ console.error('Backup run-now failed:', error);
111
+ res.status(500).json({ error: error.message || 'Backup failed' });
112
+ }
113
+ });
114
+
50
115
  module.exports = router;
package/backend/server.js CHANGED
@@ -41,15 +41,18 @@ const isNodeMode = process.env.PLUM_MODE === 'node';
41
41
  const port = parseInt(process.env.PORT || '3001', 10);
42
42
 
43
43
  let cronService = null;
44
+ let backupCronService = null;
44
45
  if (!isNodeMode) {
45
46
  const socketHandler = require('./websockets/socketHandler.js');
46
47
  cronService = require('./services/cronService');
48
+ backupCronService = require('./services/backupCronService');
47
49
  socketHandler(io);
48
50
  cronService.setSocketIO(io);
49
51
  }
50
52
 
51
53
  async function start() {
52
54
  if (cronService) await cronService.init();
55
+ if (backupCronService) await backupCronService.init();
53
56
 
54
57
  server.listen(port, async () => {
55
58
  console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
@@ -0,0 +1,82 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ const cron = require('node-cron');
19
+ const prisma = require('./prisma');
20
+ const backupService = require('./backupService');
21
+
22
+ let scheduledJob = null;
23
+
24
+ const runBackup = async () => {
25
+ let project;
26
+ try {
27
+ project = await prisma.project.findUnique({ where: { id: 1 } });
28
+ } catch (err) {
29
+ console.error('❌ Backup: could not read project config:', err.message);
30
+ return;
31
+ }
32
+
33
+ if (!project?.backupEnabled) return;
34
+
35
+ try {
36
+ const data = await backupService.exportAll();
37
+ const key = await backupService.uploadToS3(data, project);
38
+
39
+ await prisma.project.update({
40
+ where: { id: 1 },
41
+ data: {
42
+ backupLastRunAt: new Date(),
43
+ backupLastStatus: `success:${key}`
44
+ }
45
+ });
46
+ console.log(`✅ Backup uploaded: ${key}`);
47
+ } catch (err) {
48
+ console.error('❌ Backup failed:', err.message);
49
+ try {
50
+ await prisma.project.update({
51
+ where: { id: 1 },
52
+ data: {
53
+ backupLastRunAt: new Date(),
54
+ backupLastStatus: `error:${err.message}`
55
+ }
56
+ });
57
+ } catch {}
58
+ }
59
+ };
60
+
61
+ const schedule = (cronExpr, enabled) => {
62
+ if (scheduledJob) {
63
+ scheduledJob.stop();
64
+ scheduledJob = null;
65
+ }
66
+ if (!enabled || !cronExpr || !cron.validate(cronExpr)) return;
67
+ scheduledJob = cron.schedule(cronExpr, runBackup);
68
+ console.log(`⏰ Backup scheduled: ${cronExpr}`);
69
+ };
70
+
71
+ const init = async () => {
72
+ try {
73
+ const project = await prisma.project.findUnique({ where: { id: 1 } });
74
+ schedule(project?.backupCron, project?.backupEnabled);
75
+ } catch (err) {
76
+ console.error('Failed to initialize backup cron:', err.message);
77
+ }
78
+ };
79
+
80
+ const reload = init;
81
+
82
+ module.exports = { init, reload, runBackup };
@@ -17,45 +17,272 @@
17
17
 
18
18
  const prisma = require('./prisma');
19
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Export — all data except reports (reports are too large; use pg_dump instead)
22
+ // ---------------------------------------------------------------------------
23
+
20
24
  const exportAll = async () => {
21
- const [cronJobs, project] = await Promise.all([
25
+ const [cronJobs, project, testSuites, testRuns, users, runners] = await Promise.all([
22
26
  prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
23
- prisma.project.findUnique({ where: { id: 1 } })
27
+ prisma.project.findUnique({ where: { id: 1 } }),
28
+ prisma.testSuite.findMany({
29
+ orderBy: { createdAt: 'asc' },
30
+ include: {
31
+ cases: {
32
+ include: { steps: { orderBy: { order: 'asc' } } },
33
+ orderBy: { createdAt: 'asc' }
34
+ }
35
+ }
36
+ }),
37
+ prisma.testRun.findMany({
38
+ orderBy: { createdAt: 'asc' },
39
+ include: { entries: { orderBy: { order: 'asc' } } }
40
+ }),
41
+ prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
42
+ prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
24
43
  ]);
25
44
 
26
45
  return {
27
- version: '1',
46
+ version: '2',
28
47
  exportedAt: new Date().toISOString(),
29
- cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, ...r }) => r),
30
- project: project ? { name: project.name, logoUrl: project.logoUrl } : null
48
+ disclaimer:
49
+ 'Reports are not included in this backup. Use pg_dump on the PostgreSQL volume to back up report history.',
50
+ cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
51
+ project: project
52
+ ? {
53
+ name: project.name,
54
+ logoUrl: project.logoUrl,
55
+ testCasePrefix: project.testCasePrefix,
56
+ testSuitePrefix: project.testSuitePrefix,
57
+ discordWebhookUrl: project.discordWebhookUrl,
58
+ slackWebhookUrl: project.slackWebhookUrl,
59
+ notifyPublicUrl: project.notifyPublicUrl
60
+ }
61
+ : null,
62
+ users: users.map(({ updatedAt: _, ...u }) => u),
63
+ runners: runners.map(({ createdAt: _, cronJobs: __, reports: ___, ...r }) => r),
64
+ testSuites: testSuites.map(({ cases, ...suite }) => ({
65
+ ...suite,
66
+ cases: cases.map(({ steps, runEntries: _, history: __, ...tc }) => ({
67
+ ...tc,
68
+ steps: steps.map(({ createdAt: ___, ...step }) => step)
69
+ }))
70
+ })),
71
+ testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
72
+ ...run,
73
+ entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
74
+ }))
31
75
  };
32
76
  };
33
77
 
34
- const importAll = async ({ cronJobs = [], project = null }, cronService) => {
35
- await prisma.$transaction(async (tx) => {
36
- for (const job of cronJobs) {
37
- await tx.cronJob.upsert({
38
- where: { taskName: job.taskName },
39
- create: {
40
- taskName: job.taskName,
41
- cronExpression: job.cronExpression,
42
- tags: job.tags,
43
- workers: job.workers ?? 1
44
- },
45
- update: { cronExpression: job.cronExpression, tags: job.tags, workers: job.workers ?? 1 }
46
- });
78
+ // ---------------------------------------------------------------------------
79
+ // Import upsert all exported data, preserve IDs for relational integrity
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const importAll = async (
83
+ { cronJobs = [], project = null, users = [], runners = [], testSuites = [], testRuns = [] },
84
+ cronService
85
+ ) => {
86
+ await prisma.$transaction(
87
+ async (tx) => {
88
+ // 1. Users (needed before suites/runs reference createdById)
89
+ for (const user of users) {
90
+ await tx.user.upsert({
91
+ where: { email: user.email },
92
+ create: user,
93
+ update: { name: user.name, role: user.role }
94
+ });
95
+ }
96
+
97
+ // 2. Runners
98
+ for (const runner of runners) {
99
+ await tx.runner.upsert({
100
+ where: { id: runner.id },
101
+ create: runner,
102
+ update: {
103
+ name: runner.name,
104
+ url: runner.url,
105
+ token: runner.token,
106
+ browser: runner.browser
107
+ }
108
+ });
109
+ }
110
+
111
+ // 3. CronJobs
112
+ for (const job of cronJobs) {
113
+ await tx.cronJob.upsert({
114
+ where: { taskName: job.taskName },
115
+ create: {
116
+ taskName: job.taskName,
117
+ cronExpression: job.cronExpression,
118
+ tags: job.tags,
119
+ workers: job.workers ?? 1,
120
+ browser: job.browser ?? 'chromium',
121
+ enabled: job.enabled ?? true,
122
+ runnerIds: job.runnerIds ?? 'built-in',
123
+ notifyDiscord: job.notifyDiscord ?? false,
124
+ notifySlack: job.notifySlack ?? false
125
+ },
126
+ update: {
127
+ cronExpression: job.cronExpression,
128
+ tags: job.tags,
129
+ workers: job.workers ?? 1,
130
+ browser: job.browser ?? 'chromium',
131
+ runnerIds: job.runnerIds ?? 'built-in',
132
+ notifyDiscord: job.notifyDiscord ?? false,
133
+ notifySlack: job.notifySlack ?? false
134
+ }
135
+ });
136
+ }
137
+
138
+ // 4. Project settings
139
+ if (project) {
140
+ await tx.project.upsert({
141
+ where: { id: 1 },
142
+ create: { id: 1, ...project },
143
+ update: project
144
+ });
145
+ }
146
+
147
+ // 5. Test suites + cases + steps
148
+ for (const suite of testSuites) {
149
+ const { cases = [], ...suiteData } = suite;
150
+ await tx.testSuite.upsert({
151
+ where: { displayId: suiteData.displayId },
152
+ create: suiteData,
153
+ update: {
154
+ name: suiteData.name,
155
+ description: suiteData.description,
156
+ priority: suiteData.priority
157
+ }
158
+ });
159
+
160
+ for (const tc of cases) {
161
+ const { steps = [], ...caseData } = tc;
162
+ await tx.testCase.upsert({
163
+ where: { displayId: caseData.displayId },
164
+ create: caseData,
165
+ update: {
166
+ title: caseData.title,
167
+ description: caseData.description,
168
+ priority: caseData.priority,
169
+ isAutomated: caseData.isAutomated
170
+ }
171
+ });
172
+
173
+ // Replace all steps for this case (order may have changed)
174
+ await tx.testStep.deleteMany({ where: { caseId: caseData.id } });
175
+ for (const step of steps) {
176
+ await tx.testStep.create({ data: step });
177
+ }
178
+ }
179
+ }
180
+
181
+ // 6. Test runs + entries
182
+ for (const run of testRuns) {
183
+ const { entries = [], ...runData } = run;
184
+ await tx.testRun.upsert({
185
+ where: { id: runData.id },
186
+ create: runData,
187
+ update: { title: runData.title, status: runData.status }
188
+ });
189
+
190
+ for (const entry of entries) {
191
+ await tx.testRunEntry.upsert({
192
+ where: { id: entry.id },
193
+ create: entry,
194
+ update: { status: entry.status, notes: entry.notes, order: entry.order }
195
+ });
196
+ }
197
+ }
198
+ },
199
+ { timeout: 30000 }
200
+ );
201
+
202
+ if (cronService) await cronService.reload();
203
+ };
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ const uploadToS3 = async (jsonData, config) => {
210
+ const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
211
+
212
+ const {
213
+ backupS3Endpoint,
214
+ backupS3Region,
215
+ backupS3Bucket,
216
+ backupS3AccessKey,
217
+ backupS3SecretKey,
218
+ backupS3Prefix
219
+ } = config;
220
+
221
+ const clientConfig = {
222
+ region: backupS3Region || 'auto',
223
+ credentials: {
224
+ accessKeyId: backupS3AccessKey,
225
+ secretAccessKey: backupS3SecretKey
47
226
  }
227
+ };
228
+ if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
229
+
230
+ const client = new S3Client(clientConfig);
231
+
232
+ const date = new Date().toISOString().slice(0, 10);
233
+ const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
234
+ const key = `${prefix}plum-backup-${date}.json`;
235
+
236
+ await client.send(
237
+ new PutObjectCommand({
238
+ Bucket: backupS3Bucket,
239
+ Key: key,
240
+ Body: JSON.stringify(jsonData, null, 2),
241
+ ContentType: 'application/json'
242
+ })
243
+ );
244
+
245
+ return key;
246
+ };
247
+
248
+ const testS3Connection = async (config) => {
249
+ const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
48
250
 
49
- if (project) {
50
- await tx.project.upsert({
51
- where: { id: 1 },
52
- create: { id: 1, name: project.name ?? '', logoUrl: project.logoUrl ?? '' },
53
- update: { name: project.name ?? '', logoUrl: project.logoUrl ?? '' }
54
- });
251
+ const {
252
+ backupS3Endpoint,
253
+ backupS3Region,
254
+ backupS3Bucket,
255
+ backupS3AccessKey,
256
+ backupS3SecretKey,
257
+ backupS3Prefix
258
+ } = config;
259
+
260
+ const clientConfig = {
261
+ region: backupS3Region || 'auto',
262
+ credentials: {
263
+ accessKeyId: backupS3AccessKey,
264
+ secretAccessKey: backupS3SecretKey
55
265
  }
56
- });
266
+ };
267
+ if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
57
268
 
58
- if (cronService) await cronService.reload();
269
+ const client = new S3Client(clientConfig);
270
+ const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
271
+ const key = `${prefix}.plum-connection-test`;
272
+
273
+ await client.send(
274
+ new PutObjectCommand({
275
+ Bucket: backupS3Bucket,
276
+ Key: key,
277
+ Body: 'ok',
278
+ ContentType: 'text/plain'
279
+ })
280
+ );
281
+
282
+ // Clean up the test file
283
+ try {
284
+ await client.send(new DeleteObjectCommand({ Bucket: backupS3Bucket, Key: key }));
285
+ } catch {}
59
286
  };
60
287
 
61
- module.exports = { exportAll, importAll };
288
+ module.exports = { exportAll, importAll, uploadToS3, testS3Connection };
@@ -70,11 +70,56 @@ const updateWebhooks = async ({ discordWebhookUrl, slackWebhookUrl, notifyPublic
70
70
  });
71
71
  };
72
72
 
73
+ const getBackupConfig = async () => {
74
+ const project = await getProject();
75
+ return {
76
+ backupEnabled: project.backupEnabled,
77
+ backupCron: project.backupCron,
78
+ backupS3Endpoint: project.backupS3Endpoint,
79
+ backupS3Region: project.backupS3Region,
80
+ backupS3Bucket: project.backupS3Bucket,
81
+ backupS3AccessKey: project.backupS3AccessKey,
82
+ backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
83
+ backupS3Prefix: project.backupS3Prefix,
84
+ backupLastRunAt: project.backupLastRunAt,
85
+ backupLastStatus: project.backupLastStatus
86
+ };
87
+ };
88
+
89
+ const updateBackupConfig = async ({
90
+ backupEnabled,
91
+ backupCron,
92
+ backupS3Endpoint,
93
+ backupS3Region,
94
+ backupS3Bucket,
95
+ backupS3AccessKey,
96
+ backupS3SecretKey,
97
+ backupS3Prefix
98
+ }) => {
99
+ const update = {
100
+ ...(backupEnabled !== undefined && { backupEnabled }),
101
+ ...(backupCron !== undefined && { backupCron }),
102
+ ...(backupS3Endpoint !== undefined && { backupS3Endpoint }),
103
+ ...(backupS3Region !== undefined && { backupS3Region }),
104
+ ...(backupS3Bucket !== undefined && { backupS3Bucket }),
105
+ ...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
106
+ ...(backupS3SecretKey && { backupS3SecretKey }),
107
+ ...(backupS3Prefix !== undefined && { backupS3Prefix })
108
+ };
109
+ return prisma.project.upsert({
110
+ where: { id: 1 },
111
+ create: { id: 1, ...update },
112
+ update
113
+ });
114
+ };
115
+
73
116
  module.exports = {
74
117
  getProject,
75
118
  updateProject,
76
119
  getTestPrefixes,
77
120
  updateTestPrefixes,
78
121
  getWebhooks,
79
- updateWebhooks
122
+ updateWebhooks,
123
+ getBackupConfig,
124
+ updateBackupConfig
80
125
  };
@@ -47,6 +47,47 @@ export async function importBackup(data) {
47
47
  return res.json();
48
48
  }
49
49
 
50
+ export async function fetchBackupConfig() {
51
+ const res = await fetch(`${API_BASE}/backup/config`);
52
+ if (!res.ok)
53
+ return {
54
+ backupEnabled: false,
55
+ backupCron: '0 2 * * *',
56
+ backupS3Endpoint: '',
57
+ backupS3Region: '',
58
+ backupS3Bucket: '',
59
+ backupS3AccessKey: '',
60
+ backupS3SecretKeySet: false,
61
+ backupS3Prefix: '',
62
+ backupLastRunAt: null,
63
+ backupLastStatus: ''
64
+ };
65
+ return res.json();
66
+ }
67
+
68
+ export async function saveBackupConfig(config) {
69
+ const res = await fetch(`${API_BASE}/backup/config`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify(config)
73
+ });
74
+ return res.json();
75
+ }
76
+
77
+ export async function testBackupS3(config) {
78
+ const res = await fetch(`${API_BASE}/backup/test-s3`, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify(config)
82
+ });
83
+ return res.json();
84
+ }
85
+
86
+ export async function runBackupNow() {
87
+ const res = await fetch(`${API_BASE}/backup/run-now`, { method: 'POST' });
88
+ return res.json();
89
+ }
90
+
50
91
  export async function fetchIntegrations() {
51
92
  const res = await fetch(`${API_BASE}/settings/integrations`);
52
93
  if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };