From f469d43c8ed1568ec5c2f496feded4cd8863b6cd Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Fri, 30 Jan 2026 00:30:50 +0000 Subject: [PATCH] org export as json --- packages/backend/src/db/queries/export.ts | 77 +++++++++++++++++++ packages/backend/src/db/queries/index.ts | 1 + packages/backend/src/index.ts | 1 + packages/backend/src/routes/index.ts | 2 + .../backend/src/routes/organisation/export.ts | 43 +++++++++++ .../frontend/src/components/organisations.tsx | 34 ++++++++ packages/shared/src/contract.ts | 10 +++ 7 files changed, 168 insertions(+) create mode 100644 packages/backend/src/db/queries/export.ts create mode 100644 packages/backend/src/routes/organisation/export.ts diff --git a/packages/backend/src/db/queries/export.ts b/packages/backend/src/db/queries/export.ts new file mode 100644 index 0000000..cae57aa --- /dev/null +++ b/packages/backend/src/db/queries/export.ts @@ -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, + }; +} diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index c9f3a96..c1e56b6 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -1,4 +1,5 @@ export * from "./email-verification"; +export * from "./export"; export * from "./issue-comments"; export * from "./issues"; export * from "./organisations"; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a906004..755395e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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))), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 1d34d50..7ba6e1e 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -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, diff --git a/packages/backend/src/routes/organisation/export.ts b/packages/backend/src/routes/organisation/export.ts new file mode 100644 index 0000000..38072d3 --- /dev/null +++ b/packages/backend/src/routes/organisation/export.ts @@ -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); +} diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index 36fb2ba..9b5821b 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -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 && (
+