Skip to content

Commit 242d91d

Browse files
authored
Merge pull request #185 from openscript-ch/23-implement-sql-dump-export-functionality
23 implement sql dump export functionality
2 parents abd84ee + d55b91c commit 242d91d

File tree

17 files changed

+393
-70
lines changed

17 files changed

+393
-70
lines changed

.changeset/late-ducks-repair.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@quassel/frontend": minor
3+
"@quassel/backend": minor
4+
---
5+
6+
Add database dump export

apps/backend/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ async function bootstrap() {
1313
app.enableCors({
1414
credentials: true,
1515
origin: configService.get("cors.origin"),
16+
exposedHeaders: ["Content-Disposition"],
1617
});
1718
app.enableShutdownHooks();
1819
app.useGlobalPipes(new ValidationPipe());
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Controller, Get, Response } from "@nestjs/common";
2+
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
3+
import { FastifyReply } from "fastify";
4+
import { ExportService } from "./export.service";
5+
6+
@Controller("export")
7+
export class ExportController {
8+
constructor(private readonly exportService: ExportService) {}
9+
10+
@Get()
11+
@ApiOperation({ summary: "Offers the backends data for download" })
12+
@ApiResponse({
13+
status: 200,
14+
description: "Database dump file",
15+
content: {
16+
"text/sql": {
17+
schema: { type: "string", format: "binary" },
18+
},
19+
},
20+
headers: {
21+
"Content-Disposition": {
22+
description: "Attachment dump.sql",
23+
schema: { type: "string", example: 'attachment; filename="quassel-database-dump-01-01-01_15-12.sql"' },
24+
},
25+
},
26+
})
27+
async get(@Response() res: FastifyReply) {
28+
try {
29+
const data = await this.exportService.fullDatabaseDump();
30+
const buffer = Buffer.from(data, "utf-8");
31+
const dateTime = new Date().toISOString().replace(/:/g, "-").replace("T", "_").replace(/\..+/, "");
32+
res.header("Content-Disposition", `attachment; filename="quassel-database-dump-${dateTime}.sql"`);
33+
res.header("Content-Type", "text/sql");
34+
res.header("Content-Length", buffer.byteLength.toString());
35+
res.send(buffer);
36+
} catch (error) {
37+
res.status(500).send(error.message);
38+
}
39+
}
40+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Injectable } from "@nestjs/common";
2+
import { exec } from "node:child_process";
3+
import { promisify } from "node:util";
4+
import { ConfigService } from "../../config/config.service";
5+
6+
const execPromise = promisify(exec);
7+
8+
@Injectable()
9+
export class ExportService {
10+
constructor(private readonly configService: ConfigService) {}
11+
12+
async fullDatabaseDump() {
13+
try {
14+
const user = this.configService.get("database.user");
15+
const database = this.configService.get("database.name");
16+
const password = this.configService.get("database.password");
17+
const host = this.configService.get("database.host");
18+
const port = this.configService.get("database.port");
19+
20+
const command = `PGPASSWORD=${password} pg_dump -U ${user} -h ${host} -p ${port} ${database}`;
21+
22+
const { stdout, stderr } = await execPromise(command);
23+
if (stderr) {
24+
throw new Error(stderr);
25+
}
26+
return stdout;
27+
} catch (error) {
28+
throw new Error(`Database dump failed: ${error.message}`);
29+
}
30+
}
31+
}

apps/backend/src/system/health/health.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Controller, Get } from "@nestjs/common";
22
import { HealthCheck, HealthCheckService, MikroOrmHealthIndicator } from "@nestjs/terminus";
33
import { Public } from "../session/public.decorator";
4+
import { ApiOperation } from "@nestjs/swagger";
45

56
@Controller("health")
67
export class HealthController {
@@ -10,6 +11,7 @@ export class HealthController {
1011
) {}
1112

1213
@Get()
14+
@ApiOperation({ summary: "Returns the backends health information" })
1315
@HealthCheck()
1416
@Public()
1517
get() {

apps/backend/src/system/status/status.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Controller, Get } from "@nestjs/common";
22
import { Public } from "../session/public.decorator";
33
import { version } from "../../../package.json";
4+
import { ApiOperation } from "@nestjs/swagger";
45

56
@Controller("status")
67
export class StatusController {
78
@Get()
9+
@ApiOperation({ summary: "Returns the backends status information" })
810
@Public()
911
get() {
1012
return { version };

apps/backend/src/system/system.module.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ import { RolesGuard } from "./users/roles.guard";
1111
import { HealthController } from "./health/health.controller";
1212
import { TerminusModule } from "@nestjs/terminus";
1313
import { StatusController } from "./status/status.controller";
14+
import { ExportController } from "./export/export.controller";
15+
import { ExportService } from "./export/export.service";
16+
import { ConfigModule } from "../config/config.module";
1417

1518
@Module({
16-
imports: [MikroOrmModule.forFeature([User]), TerminusModule],
17-
controllers: [UsersController, SessionController, HealthController, StatusController],
18-
providers: [UsersService, SessionService, { provide: APP_GUARD, useClass: SessionGuard }, { provide: APP_GUARD, useClass: RolesGuard }],
19+
imports: [MikroOrmModule.forFeature([User]), TerminusModule, ConfigModule],
20+
controllers: [UsersController, SessionController, HealthController, StatusController, ExportController],
21+
providers: [
22+
UsersService,
23+
SessionService,
24+
ExportService,
25+
{ provide: APP_GUARD, useClass: SessionGuard },
26+
{ provide: APP_GUARD, useClass: RolesGuard },
27+
],
1928
})
2029
export class SystemModule {}

apps/frontend/package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
"@nanostores/persistent": "^0.10.2",
2222
"@nanostores/react": "^0.8.2",
2323
"@quassel/ui": "workspace:*",
24-
"@tanstack/react-query": "^5.62.2",
25-
"@tanstack/react-router": "^1.85.4",
24+
"@tanstack/react-query": "^5.62.3",
25+
"@tanstack/react-router": "^1.87.0",
2626
"nanostores": "^0.11.3",
27-
"openapi-fetch": "0.13.1",
28-
"openapi-react-query": "0.2.6",
27+
"openapi-fetch": "0.13.3",
28+
"openapi-react-query": "0.2.8",
2929
"react": "^18.3.1",
3030
"react-dom": "^18.3.1"
3131
},
@@ -35,11 +35,12 @@
3535
"@testing-library/jest-dom": "^6.6.3",
3636
"@testing-library/react": "^16.0.1",
3737
"@types/react": "^18.3.12",
38-
"@types/react-dom": "^18.3.1",
38+
"@types/react-dom": "^19.0.1",
3939
"@vitejs/plugin-react": "^4.3.4",
4040
"@vitest/coverage-v8": "2.1.8",
4141
"jsdom": "^25.0.1",
4242
"openapi-typescript": "^7.4.4",
43+
"openapi-typescript-helpers": "^0.0.15",
4344
"typescript": "^5.7.2",
4445
"vite": "^6.0.2",
4546
"vitest": "^2.1.8"

apps/frontend/src/api.gen.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface paths {
6767
path?: never;
6868
cookie?: never;
6969
};
70+
/** Returns the backends health information */
7071
get: operations["HealthController_get"];
7172
put?: never;
7273
post?: never;
@@ -83,6 +84,7 @@ export interface paths {
8384
path?: never;
8485
cookie?: never;
8586
};
87+
/** Returns the backends status information */
8688
get: operations["StatusController_get"];
8789
put?: never;
8890
post?: never;
@@ -92,6 +94,23 @@ export interface paths {
9294
patch?: never;
9395
trace?: never;
9496
};
97+
"/export": {
98+
parameters: {
99+
query?: never;
100+
header?: never;
101+
path?: never;
102+
cookie?: never;
103+
};
104+
/** Offers the backends data for download */
105+
get: operations["ExportController_get"];
106+
put?: never;
107+
post?: never;
108+
delete?: never;
109+
options?: never;
110+
head?: never;
111+
patch?: never;
112+
trace?: never;
113+
};
95114
"/carers": {
96115
parameters: {
97116
query?: never;
@@ -1272,6 +1291,28 @@ export interface operations {
12721291
};
12731292
};
12741293
};
1294+
ExportController_get: {
1295+
parameters: {
1296+
query?: never;
1297+
header?: never;
1298+
path?: never;
1299+
cookie?: never;
1300+
};
1301+
requestBody?: never;
1302+
responses: {
1303+
/** @description Database dump file */
1304+
200: {
1305+
headers: {
1306+
/** @description Attachment dump.sql */
1307+
"Content-Disposition"?: string;
1308+
[name: string]: unknown;
1309+
};
1310+
content: {
1311+
"text/sql": string;
1312+
};
1313+
};
1314+
};
1315+
};
12751316
CarersController_index: {
12761317
parameters: {
12771318
query?: never;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useState } from "react";
2+
3+
/**
4+
* This hook is used to download a file from a given URL.
5+
* It handles the download progress and errors.
6+
*
7+
* @params fileUrl: The URL of the file to download.
8+
* @params fileName: The name of the file to download.
9+
* @params response: A function that returns a promise that resolves to a Response object.
10+
*/
11+
export const useDownload = (fileUrl: string, fileName: string, response?: () => Promise<Response>) => {
12+
const [error, setError] = useState<Error | unknown | null>(null);
13+
const [isDownloading, setIsDownloading] = useState<boolean>(false);
14+
const [progress, setProgress] = useState<number | null>(null);
15+
16+
const handleResponse = async (response: Response): Promise<string> => {
17+
if (!response.ok) {
18+
throw new Error("Could not download file");
19+
}
20+
21+
const contentLength = response.headers.get("content-length");
22+
const reader = response.body?.getReader();
23+
24+
if (!contentLength || !reader) {
25+
const blob = await response.blob();
26+
27+
return createBlobURL(blob);
28+
}
29+
30+
const stream = await getStream(contentLength, reader);
31+
const newResponse = new Response(stream);
32+
const blob = await newResponse.blob();
33+
34+
return createBlobURL(blob);
35+
};
36+
37+
const getStream = async (contentLength: string, reader: ReadableStreamDefaultReader<Uint8Array>): Promise<ReadableStream<Uint8Array>> => {
38+
let loaded = 0;
39+
const total = parseInt(contentLength, 10);
40+
41+
return new ReadableStream<Uint8Array>({
42+
async start(controller) {
43+
try {
44+
for (;;) {
45+
const { done, value } = await reader.read();
46+
47+
if (done) break;
48+
49+
loaded += value.byteLength;
50+
const percentage = Math.trunc((loaded / total) * 100);
51+
setProgress(percentage);
52+
controller.enqueue(value);
53+
}
54+
} catch (error) {
55+
controller.error(error);
56+
throw error;
57+
} finally {
58+
controller.close();
59+
}
60+
},
61+
});
62+
};
63+
64+
const createBlobURL = (blob: Blob): string => {
65+
return window.URL.createObjectURL(blob);
66+
};
67+
68+
const handleDownload = (fileName: string, url: string) => {
69+
const link = document.createElement("a");
70+
71+
link.href = url;
72+
link.setAttribute("download", fileName);
73+
document.body.appendChild(link);
74+
link.click();
75+
document.body.removeChild(link);
76+
window.URL.revokeObjectURL(url);
77+
};
78+
79+
const downloadFile = async () => {
80+
setIsDownloading(true);
81+
setError(null);
82+
setProgress(null);
83+
84+
try {
85+
const res = response ? await response() : await fetch(fileUrl);
86+
const url = await handleResponse(res);
87+
88+
const headerFileName = res.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1];
89+
90+
handleDownload(headerFileName || fileName, url);
91+
} catch (error) {
92+
setError(error);
93+
} finally {
94+
setIsDownloading(false);
95+
}
96+
};
97+
98+
return {
99+
error,
100+
isDownloading,
101+
progress,
102+
downloadFile,
103+
};
104+
};

0 commit comments

Comments
 (0)