mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
org export as json
This commit is contained in:
77
packages/backend/src/db/queries/export.ts
Normal file
77
packages/backend/src/db/queries/export.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueComment,
|
||||
Organisation,
|
||||
OrganisationMember,
|
||||
Project,
|
||||
Sprint,
|
||||
TimedSession,
|
||||
} from "@sprint/shared";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function exportOrganisation(organisationId: number) {
|
||||
const organisation = await db
|
||||
.select()
|
||||
.from(Organisation)
|
||||
.where(eq(Organisation.id, organisationId))
|
||||
.limit(1);
|
||||
if (!organisation[0]) return null;
|
||||
|
||||
const orgData = organisation[0];
|
||||
|
||||
// get members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(OrganisationMember)
|
||||
.where(eq(OrganisationMember.organisationId, organisationId));
|
||||
|
||||
// get projects
|
||||
const projects = await db.select().from(Project).where(eq(Project.organisationId, organisationId));
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
// get sprints
|
||||
const sprints =
|
||||
projectIds.length > 0
|
||||
? await db.select().from(Sprint).where(inArray(Sprint.projectId, projectIds))
|
||||
: [];
|
||||
|
||||
// get issues
|
||||
const issues =
|
||||
projectIds.length > 0
|
||||
? await db.select().from(Issue).where(inArray(Issue.projectId, projectIds))
|
||||
: [];
|
||||
|
||||
const issueIds = issues.map((i) => i.id);
|
||||
|
||||
// get issue assignees
|
||||
const issueAssignees =
|
||||
issueIds.length > 0
|
||||
? await db.select().from(IssueAssignee).where(inArray(IssueAssignee.issueId, issueIds))
|
||||
: [];
|
||||
|
||||
// get issue comments
|
||||
const issueComments =
|
||||
issueIds.length > 0
|
||||
? await db.select().from(IssueComment).where(inArray(IssueComment.issueId, issueIds))
|
||||
: [];
|
||||
|
||||
// get timed sessions - limited to issues in this org
|
||||
const timedSessions =
|
||||
issueIds.length > 0
|
||||
? await db.select().from(TimedSession).where(inArray(TimedSession.issueId, issueIds))
|
||||
: [];
|
||||
|
||||
return {
|
||||
organisation: orgData,
|
||||
members,
|
||||
projects,
|
||||
sprints,
|
||||
issues,
|
||||
issueAssignees,
|
||||
issueComments,
|
||||
timedSessions,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./email-verification";
|
||||
export * from "./export";
|
||||
export * from "./issue-comments";
|
||||
export * from "./issues";
|
||||
export * from "./organisations";
|
||||
|
||||
@@ -65,6 +65,7 @@ const main = async () => {
|
||||
|
||||
"/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))),
|
||||
"/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)),
|
||||
"/organisation/export": withGlobalAuthed(withAuth(routes.organisationExport)),
|
||||
"/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))),
|
||||
"/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))),
|
||||
"/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
|
||||
|
||||
@@ -22,6 +22,7 @@ import organisationById from "./organisation/by-id";
|
||||
import organisationsByUser from "./organisation/by-user";
|
||||
import organisationCreate from "./organisation/create";
|
||||
import organisationDelete from "./organisation/delete";
|
||||
import organisationExport from "./organisation/export";
|
||||
import organisationMemberTimeTracking from "./organisation/member-time-tracking";
|
||||
import organisationMembers from "./organisation/members";
|
||||
import organisationRemoveMember from "./organisation/remove-member";
|
||||
@@ -84,6 +85,7 @@ export const routes = {
|
||||
|
||||
organisationCreate,
|
||||
organisationById,
|
||||
organisationExport,
|
||||
organisationUpdate,
|
||||
organisationDelete,
|
||||
organisationAddMember,
|
||||
|
||||
43
packages/backend/src/routes/organisation/export.ts
Normal file
43
packages/backend/src/routes/organisation/export.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { OrgByIdQuerySchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { exportOrganisation } from "../../db/queries/export";
|
||||
import { getOrganisationById, getOrganisationMemberRole } from "../../db/queries/organisations";
|
||||
import { errorResponse, parseQueryParams } from "../../validation";
|
||||
|
||||
export default async function organisationExport(req: AuthedRequest) {
|
||||
const url = new URL(req.url);
|
||||
const parsed = parseQueryParams(url, OrgByIdQuerySchema);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
const { id } = parsed.data;
|
||||
const { userId } = req;
|
||||
|
||||
// check if organisation exists
|
||||
const organisation = await getOrganisationById(id);
|
||||
if (!organisation) {
|
||||
return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
// check if user is admin or owner
|
||||
const memberRole = await getOrganisationMemberRole(id, userId);
|
||||
if (!memberRole || (memberRole.role !== "owner" && memberRole.role !== "admin")) {
|
||||
return errorResponse("only organisation admins and owners can export data", "FORBIDDEN", 403);
|
||||
}
|
||||
|
||||
const exportData = await exportOrganisation(id);
|
||||
if (!exportData) {
|
||||
return errorResponse("failed to export organisation data", "EXPORT_FAILED", 500);
|
||||
}
|
||||
|
||||
// add metadata to export
|
||||
const exportWithMetadata = {
|
||||
...exportData,
|
||||
_metadata: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedBy: userId,
|
||||
version: "1.0",
|
||||
},
|
||||
};
|
||||
|
||||
return Response.json(exportWithMetadata);
|
||||
}
|
||||
@@ -255,6 +255,37 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
toast.success(`Downloaded time tracking data as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
const downloadOrganisationExport = async () => {
|
||||
if (!selectedOrganisation) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await apiClient.organisationExport({
|
||||
query: { id: selectedOrganisation.Organisation.id },
|
||||
});
|
||||
if (error || !data) {
|
||||
throw new Error(error ?? "failed to export organisation");
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selectedOrganisation.Organisation.slug}-export.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Downloaded ${selectedOrganisation.Organisation.name} export`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Error exporting ${selectedOrganisation.Organisation.name}: ${String(err)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
|
||||
@@ -885,6 +916,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button variant="outline" size="sm" onClick={() => void downloadOrganisationExport()}>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
|
||||
<Icon icon="edit" className="size-4" />
|
||||
Edit
|
||||
|
||||
@@ -331,6 +331,16 @@ export const apiContract = c.router({
|
||||
404: ApiErrorSchema,
|
||||
},
|
||||
},
|
||||
organisationExport: {
|
||||
method: "GET",
|
||||
path: "/organisation/export",
|
||||
query: OrgByIdQuerySchema,
|
||||
responses: {
|
||||
200: z.any(),
|
||||
403: ApiErrorSchema,
|
||||
404: ApiErrorSchema,
|
||||
},
|
||||
},
|
||||
organisationUpdate: {
|
||||
method: "POST",
|
||||
path: "/organisation/update",
|
||||
|
||||
Reference in New Issue
Block a user