feat(dashboard): add support for importing/exporting cases

This commit is contained in:
Dragory 2021-11-03 00:05:53 +02:00
parent f3dae65747
commit 45941e47d6
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
9 changed files with 593 additions and 2 deletions

View file

@ -0,0 +1,163 @@
import { ApiPermissions } from "@shared/apiPermissions";
import express, { Request, Response } from "express";
import { requireGuildPermission } from "../permissions";
import { clientError, ok } from "../responses";
import { GuildCases } from "../../data/GuildCases";
import { z } from "zod";
import { Case } from "../../data/entities/Case";
const caseHandlingModeSchema = z.union([
z.literal("replace"),
z.literal("bumpExistingCases"),
z.literal("bumpImportedCases"),
]);
type CaseHandlingMode = z.infer<typeof caseHandlingModeSchema>;
const caseNoteData = z.object({
mod_id: z.string(),
mod_name: z.string(),
body: z.string(),
created_at: z.string(),
});
const caseData = z.object({
case_number: z.number(),
user_id: z.string(),
user_name: z.string(),
mod_id: z.nullable(z.string()),
mod_name: z.nullable(z.string()),
type: z.number(),
created_at: z.string(),
is_hidden: z.boolean(),
pp_id: z.nullable(z.string()),
pp_name: z.nullable(z.string()),
notes: z.array(caseNoteData),
});
const importExportData = z.object({
cases: z.array(caseData),
});
type TImportExportData = z.infer<typeof importExportData>;
export function initGuildsImportExportAPI(guildRouter: express.Router) {
const importExportRouter = express.Router();
importExportRouter.get(
"/:guildId/pre-import",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
const guildCases = GuildCases.getGuildInstance(req.params.guildId);
const minNum = await guildCases.getMinCaseNumber();
const maxNum = await guildCases.getMaxCaseNumber();
return {
minCaseNumber: minNum,
maxCaseNumber: maxNum,
};
},
);
importExportRouter.post(
"/:guildId/import",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
let data: TImportExportData;
try {
data = importExportData.parse(req.body.data);
} catch (err) {
return clientError(res, "Invalid import data format");
return;
}
let caseHandlingMode: CaseHandlingMode;
try {
caseHandlingMode = caseHandlingModeSchema.parse(req.body.caseHandlingMode);
} catch (err) {
return clientError(res, "Invalid case handling mode");
return;
}
const guildCases = GuildCases.getGuildInstance(req.params.guildId);
// Prepare cases
if (caseHandlingMode === "replace") {
// Replace existing cases
await guildCases.deleteAllCases();
} else if (caseHandlingMode === "bumpExistingCases") {
// Bump existing numbers
const maxNumberInData = data.cases.reduce((max, theCase) => Math.max(max, theCase.case_number), 0);
await guildCases.bumpCaseNumbers(maxNumberInData);
} else if (caseHandlingMode === "bumpImportedCases") {
const maxExistingNumber = await guildCases.getMaxCaseNumber();
for (const theCase of data.cases) {
theCase.case_number += maxExistingNumber;
}
}
// Import cases
for (const theCase of data.cases) {
const insertData: any = {
...theCase,
is_hidden: theCase.is_hidden ? 1 : 0,
guild_id: req.params.guildId,
notes: undefined,
};
const caseInsertData = await guildCases.createInternal(insertData);
for (const note of theCase.notes) {
await guildCases.createNote(caseInsertData.identifiers[0].id, note);
}
}
ok(res);
},
);
const exportBatchSize = 500;
importExportRouter.post(
"/:guildId/export",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
const guildCases = GuildCases.getGuildInstance(req.params.guildId);
const data: TImportExportData = {
cases: [],
};
let n = 0;
let cases: Case[];
do {
cases = await guildCases.getExportCases(n, exportBatchSize);
n += cases.length;
for (const theCase of cases) {
data.cases.push({
case_number: theCase.case_number,
user_id: theCase.user_id,
user_name: theCase.user_name,
mod_id: theCase.mod_id,
mod_name: theCase.mod_name,
type: theCase.type,
created_at: theCase.created_at,
is_hidden: theCase.is_hidden,
pp_id: theCase.pp_id,
pp_name: theCase.pp_name,
notes: theCase.notes.map((note) => ({
mod_id: note.mod_id,
mod_name: note.mod_name,
body: note.body,
created_at: note.created_at,
})),
});
}
} while (cases.length === exportBatchSize);
res.json(data);
},
);
guildRouter.use("/", importExportRouter);
}

View file

@ -0,0 +1,14 @@
import express from "express";
import { apiTokenAuthHandlers } from "../auth";
import { initGuildsMiscAPI } from "./misc";
import { initGuildsImportExportAPI } from "./importExport";
export function initGuildsAPI(app: express.Express) {
const guildRouter = express.Router();
guildRouter.use(...apiTokenAuthHandlers());
initGuildsMiscAPI(guildRouter);
initGuildsImportExportAPI(guildRouter);
app.use("/guilds", guildRouter);
}

View file

@ -0,0 +1,180 @@
import { ApiPermissions } from "@shared/apiPermissions";
import express, { Request, Response } from "express";
import { YAMLException } from "js-yaml";
import { validateGuildConfig } from "../../configValidator";
import { AllowedGuilds } from "../../data/AllowedGuilds";
import { ApiPermissionAssignments, ApiPermissionTypes } from "../../data/ApiPermissionAssignments";
import { Configs } from "../../data/Configs";
import { apiTokenAuthHandlers } from "../auth";
import { hasGuildPermission, requireGuildPermission } from "../permissions";
import { clientError, ok, serverError, unauthorized } from "../responses";
import { loadYamlSafely } from "../../utils/loadYamlSafely";
import { ObjectAliasError } from "../../utils/validateNoObjectAliases";
import { isSnowflake } from "../../utils";
import moment from "moment-timezone";
import { ApiAuditLog } from "../../data/ApiAuditLog";
import { AuditLogEventTypes } from "../../data/apiAuditLogTypes";
import { Queue } from "../../Queue";
import { GuildCases } from "../../data/GuildCases";
import { z } from "zod";
const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog();
export function initGuildsMiscAPI(router: express.Router) {
const allowedGuilds = new AllowedGuilds();
const configs = new Configs();
const miscRouter = express.Router();
miscRouter.get("/available", async (req: Request, res: Response) => {
const guilds = await allowedGuilds.getForApiUser(req.user!.userId);
res.json(guilds);
});
miscRouter.get(
"/my-permissions", // a
async (req: Request, res: Response) => {
const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);
res.json(permissions);
},
);
miscRouter.get("/:guildId", async (req: Request, res: Response) => {
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
return unauthorized(res);
}
const guild = await allowedGuilds.find(req.params.guildId);
res.json(guild);
});
miscRouter.post("/:guildId/check-permission", async (req: Request, res: Response) => {
const permission = req.body.permission;
const hasPermission = await hasGuildPermission(req.user!.userId, req.params.guildId, permission);
res.json({ result: hasPermission });
});
miscRouter.get(
"/:guildId/config",
requireGuildPermission(ApiPermissions.ReadConfig),
async (req: Request, res: Response) => {
const config = await configs.getActiveByKey(`guild-${req.params.guildId}`);
res.json({ config: config ? config.config : "" });
},
);
miscRouter.post("/:guildId/config", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => {
let config = req.body.config;
if (config == null) return clientError(res, "No config supplied");
config = config.trim() + "\n"; // Normalize start/end whitespace in the config
const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`);
if (currentConfig && config === currentConfig.config) {
return ok(res);
}
// Validate config
let parsedConfig;
try {
parsedConfig = loadYamlSafely(config);
} catch (e) {
if (e instanceof YAMLException) {
return res.status(400).json({ errors: [e.message] });
}
if (e instanceof ObjectAliasError) {
return res.status(400).json({ errors: [e.message] });
}
// tslint:disable-next-line:no-console
console.error("Error when loading YAML: " + e.message);
return serverError(res, "Server error");
}
if (parsedConfig == null) {
parsedConfig = {};
}
const error = await validateGuildConfig(parsedConfig);
if (error) {
return res.status(422).json({ errors: [error] });
}
await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId);
ok(res);
});
miscRouter.get(
"/:guildId/permissions",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId);
res.json(permissions);
},
);
const permissionManagementQueue = new Queue();
miscRouter.post(
"/:guildId/set-target-permissions",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
await permissionManagementQueue.add(async () => {
const { type, targetId, permissions, expiresAt } = req.body;
if (type !== ApiPermissionTypes.User) {
return clientError(res, "Invalid type");
}
if (!isSnowflake(targetId)) {
return clientError(res, "Invalid targetId");
}
const validPermissions = new Set(Object.values(ApiPermissions));
validPermissions.delete(ApiPermissions.Owner);
if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {
return clientError(res, "Invalid permissions");
}
if (expiresAt != null && !moment.utc(expiresAt).isValid()) {
return clientError(res, "Invalid expiresAt");
}
const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {
return clientError(res, "Can't change owner permissions");
}
if (permissions.length === 0) {
await apiPermissionAssignments.removeUser(req.params.guildId, targetId);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
});
} else {
const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existing) {
await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: existing.expires_at,
});
} else {
await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: expiresAt,
});
}
}
ok(res);
});
},
);
router.use("/", miscRouter);
}

View file

@ -4,7 +4,7 @@ import { TokenError } from "passport-oauth2";
import { initArchives } from "./archives";
import { initAuth } from "./auth";
import { initDocs } from "./docs";
import { initGuildsAPI } from "./guilds";
import { initGuildsAPI } from "./guilds/index";
import { clientError, error, notFound } from "./responses";
import { startBackgroundTasks } from "./tasks";

View file

@ -4,7 +4,8 @@ import { CaseTypes } from "./CaseTypes";
import { connection } from "./db";
import { Case } from "./entities/Case";
import { CaseNote } from "./entities/CaseNote";
import moment = require("moment-timezone");
import moment from "moment-timezone";
import { chunkArray } from "../utils";
import { Queue } from "../Queue";
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
@ -111,6 +112,26 @@ export class GuildCases extends BaseGuildRepository {
});
}
async getMinCaseNumber(): Promise<number> {
const result = await this.cases
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.select(["MIN(case_number) AS min_case_number"])
.getRawOne<{ min_case_number: number }>();
return result?.min_case_number || 0;
}
async getMaxCaseNumber(): Promise<number> {
const result = await this.cases
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.select(["MAX(case_number) AS max_case_number"])
.getRawOne<{ max_case_number: number }>();
return result?.max_case_number || 0;
}
async setHidden(id: number, hidden: boolean): Promise<void> {
await this.cases.update(
{ id },
@ -197,4 +218,42 @@ export class GuildCases extends BaseGuildRepository {
case_id: caseId,
});
}
async deleteAllCases(): Promise<void> {
const idRows = await this.cases
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.select(["id"])
.getRawMany<{ id: number }>();
const ids = idRows.map((r) => r.id);
const batches = chunkArray(ids, 500);
for (const batch of batches) {
await this.cases.createQueryBuilder().where("id IN (:ids)", { ids: batch }).delete().execute();
}
}
async bumpCaseNumbers(amount: number): Promise<void> {
await this.cases
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.update()
.set({
case_number: () => `case_number + ${parseInt(amount as unknown as string, 10)}`,
})
.execute();
}
getExportCases(skip: number, take: number): Promise<Case[]> {
return this.cases.find({
where: {
guild_id: this.guildId,
},
relations: ["notes"],
order: {
case_number: "ASC",
},
skip,
take,
});
}
}