Implement database administration

Add routes and admin panel elements for creating a database backup,
restoring from a backup, deleting the existing schedule, and replacing
the database with the demo schedule.  These server as crude ways to
manage the data stored in the system.
This commit is contained in:
Hornwitser 2025-06-28 01:23:52 +02:00
parent b2f48e98e0
commit e5e923bc8d
6 changed files with 190 additions and 5 deletions

View file

@ -1,12 +1,102 @@
<template>
<main>
<h1>Admin</h1>
<h2>Users</h2>
<TableUsers />
<Tabs
:tabs
default="users"
>
<template #users>
<TableUsers />
</template>
<template #database>
<p>
To backup the database you can create and download a
database snapshot. This will export all schedule and user
data stored on the server into a single JSON file that can
then later be used to replace the exsting content of the
database to restore the system to the state it was in when
the snapshot was created.
</p>
<form
action="/api/admin/database-export"
method="post"
>
<p>
<button type="submit">
Download snapshot
</button>
</p>
</form>
<fieldset>
<legend>Restore Database</legend>
<p>
To restore the system to a previous state you can load a
database snapshot obtained from previously running the
database snapshot tool above. This will delete all
changes including new events, shifts, users and user
data that has created since the snapshot was created.
</p>
<form
action="/api/admin/database-import"
enctype="multipart/form-data"
method="post"
>
<p>
<label>
Database snapshot:
<input name="snapshot" type="file">
</label>
</p>
<p>
<button type="submit">
Restore database
</button>
</p>
</form>
</fieldset>
<fieldset>
<legend>Danger Zone</legend>
<p>
If you wish to start over with the schedule, you can use
this button to delete the current schedule.
</p>
<form
action="/api/admin/delete-schedule"
method="post"
>
<p>
<button type="submit">
Delete schedule
</button>
</p>
</form>
<p>
If you wish to see a demo of how the system can be used
you can replace the existing schedule with a generated
demo schedule that shows off all the features. <b>This
will delete all existing data including users!</b>
</p>
<form
action="/api/admin/database-import-demo"
method="post"
>
<p>
<button type="submit">
Replace with demo
</button>
</p>
</form>
</fieldset>
</template>
</Tabs>
</main>
</template>
<script lang="ts" setup>
const tabs = [
{ id: "users", title: "Users" },
{ id: "database", title: "Database" },
];
</script>
<style>

View file

@ -0,0 +1,15 @@
import { readNextSessionId, readNextUserId, readSchedule, readSessions, readSubscriptions, readUsers } from "~/server/database";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"');
setHeader(event, "Content-Type", "application/json; charset=utf-8");
return {
nextUserId: await readNextUserId(),
users: await readUsers(),
nextSessionId: await readNextSessionId(),
sessions: await readSessions(),
subscriptions: await readSubscriptions(),
schedule: await readSchedule(),
};
})

View file

@ -0,0 +1,7 @@
import { writeSchedule, writeUsers } from "~/server/database";
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
await writeUsers(generateDemoAccounts());
await writeSchedule(generateDemoSchedule());
})

View file

@ -0,0 +1,44 @@
import { readNextSessionId, readNextUserId, readSessions, type ServerSession, type ServerUser, writeNextSessionId, writeNextUserId, writeSchedule, writeSessions, writeSubscriptions, writeUsers } from "~/server/database";
import type { ApiSchedule, ApiSubscription } from "~/shared/types/api";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const formData = await readMultipartFormData(event);
let snapshot: undefined | {
nextUserId: number,
users: ServerUser[],
nextSessionId: number,
sessions: ServerSession[],
subscriptions: ApiSubscription[],
schedule: ApiSchedule,
};
for (const part of formData ?? []) {
if (part.name === "snapshot") {
snapshot = JSON.parse(part.data.toString("utf-8"));
}
}
if (!snapshot) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "snapshot missing."
});
}
const currentNextUserId = await readNextUserId();
await writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
await writeUsers(snapshot.users);
const currentNextSessionId = await readNextSessionId();
await writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
const currentSessions = new Map((await readSessions()).map(session => [session.id, session]));
await writeSessions(snapshot.sessions.filter(session => {
const current = currentSessions.get(session.id);
// Only keep sessions that match the account id in both sets to avoid
// resurrecting deleted sessions. This will still cause session cross
// pollution if a snapshot from another instance is loaded here.
return current && current.account.id === session.account.id;
}));
await writeSubscriptions(snapshot.subscriptions);
await writeSchedule(snapshot.schedule);
await sendRedirect(event, "/");
})

View file

@ -0,0 +1,11 @@
import { writeSchedule } from "~/server/database";
import type { ApiSchedule } from "~/shared/types/api";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const schedule: ApiSchedule = {
id: 111,
updatedAt: new Date().toISOString(),
};
await writeSchedule(schedule);
})

View file

@ -1,6 +1,5 @@
import { readFile, unlink, writeFile } from "node:fs/promises";
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
import type { Id } from "~/shared/types/common";
export interface ServerSession {
@ -65,7 +64,10 @@ async function readJson<T>(filePath: string, fallback: T) {
}
export async function readSchedule() {
return readJson(schedulePath, generateDemoSchedule);
return readJson(schedulePath, (): ApiSchedule => ({
id: 111,
updatedAt: new Date().toISOString(),
}));
}
export async function writeSchedule(schedule: ApiSchedule) {
@ -85,6 +87,14 @@ export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
}
export async function readNextUserId() {
return await readJson(nextUserIdPath, 0);
}
export async function writeNextUserId(nextId: number) {
await writeFile(nextUserIdPath, String(nextId), "utf-8");
}
export async function nextUserId() {
let nextId = await readJson(nextUserIdPath, 0);
if (nextId === 0) {
@ -95,13 +105,21 @@ export async function nextUserId() {
}
export async function readUsers() {
return await readJson(usersPath, generateDemoAccounts as () => ServerUser[]);
return await readJson(usersPath, (): ServerUser[] => []);
}
export async function writeUsers(users: ServerUser[]) {
await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
}
export async function readNextSessionId() {
return await readJson(nextSessionIdPath, 0);
}
export async function writeNextSessionId(nextId: number) {
await writeFile(nextSessionIdPath, String(nextId), "utf-8");
}
export async function nextSessionId() {
const nextId = await readJson(nextSessionIdPath, 0);
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");