From 229bce5ee9b9ad18dc8fe91901eb8c7e08da1801 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 20:59:41 +0000 Subject: [PATCH 1/4] added cn() to textClass --- packages/frontend/src/components/org-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/org-icon.tsx b/packages/frontend/src/components/org-icon.tsx index 6950898..c27d705 100644 --- a/packages/frontend/src/components/org-icon.tsx +++ b/packages/frontend/src/components/org-icon.tsx @@ -70,7 +70,7 @@ export default function OrgIcon({ {iconURL ? ( {name} ) : ( - {getInitials(name)} + {getInitials(name)} )} ); From 13ba687ef3894d4277f1ea19b62eddea097d75b4 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 21:08:26 +0000 Subject: [PATCH 2/4] improved formatting of non-avatar org icons --- packages/frontend/src/components/org-icon.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/org-icon.tsx b/packages/frontend/src/components/org-icon.tsx index c27d705..5abc430 100644 --- a/packages/frontend/src/components/org-icon.tsx +++ b/packages/frontend/src/components/org-icon.tsx @@ -70,7 +70,9 @@ export default function OrgIcon({ {iconURL ? ( {name} ) : ( - {getInitials(name)} +
+ {getInitials(name)} +
)} ); From abc3568800348fb8385e575fcc87ce12099f1eaf Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Fri, 30 Jan 2026 00:11:12 +0000 Subject: [PATCH 3/4] filter persistence: url -> localStorage -> none (defaults) --- packages/frontend/src/pages/Issues.tsx | 115 ++++++++++++++++++------- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index ba487a9..6ea61f0 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -76,6 +76,69 @@ const parseIssueFilters = (search: string): IssuesTableFilters => { }; }; +const getFilterStorageKey = (organisationId: number | null, projectId: number | null) => { + if (!organisationId || !projectId) return null; + return `sprint.issue-filters.${organisationId}.${projectId}`; +}; +const FILTER_PARAM_KEYS = ["q", "status", "type", "assignee", "sprint", "sort"] as const; + +const hasFilterParams = (search: string) => { + const params = new URLSearchParams(search); + return FILTER_PARAM_KEYS.some((key) => params.has(key)); +}; + +const readStoredFilters = (storageKey: string): IssuesTableFilters | null => { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== "object") return null; + + const statuses = Array.isArray(parsed.statuses) ? parsed.statuses.filter(Boolean) : []; + const types = Array.isArray(parsed.types) ? parsed.types.filter(Boolean) : []; + const assignees = Array.isArray(parsed.assignees) ? parsed.assignees.filter(Boolean) : []; + const query = typeof parsed.query === "string" ? parsed.query : ""; + + let sprintId: IssuesTableFilters["sprintId"] = "all"; + if (parsed.sprintId === "none" || parsed.sprintId === "all") { + sprintId = parsed.sprintId; + } else if (typeof parsed.sprintId === "number" && !Number.isNaN(parsed.sprintId)) { + sprintId = parsed.sprintId; + } + + const sortValues: IssuesTableFilters["sort"][] = [ + "newest", + "oldest", + "title-asc", + "title-desc", + "status", + ]; + const sort = sortValues.includes(parsed.sort as IssuesTableFilters["sort"]) + ? (parsed.sort as IssuesTableFilters["sort"]) + : defaultIssuesTableFilters.sort; + + return { + ...defaultIssuesTableFilters, + query, + statuses, + types, + assignees, + sprintId, + sort, + }; + } catch { + return null; + } +}; + +const writeStoredFilters = (storageKey: string, filters: IssuesTableFilters) => { + try { + localStorage.setItem(storageKey, JSON.stringify(filters)); + } catch { + return; + } +}; + const filtersEqual = (left: IssuesTableFilters, right: IssuesTableFilters) => { if (left.query !== right.query) return false; if (left.sprintId !== right.sprintId) return false; @@ -125,8 +188,21 @@ export default function Issues() { const selectedOrganisation = useSelectedOrganisation(); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); const { data: sprintsData = [] } = useSprints(selectedProjectId); - const parsedFilters = useMemo(() => parseIssueFilters(location.search), [location.search]); - const [issueFilters, setIssueFilters] = useState(() => parsedFilters); + const filterStorageKey = useMemo( + () => getFilterStorageKey(selectedOrganisationId, selectedProjectId), + [selectedOrganisationId, selectedProjectId], + ); + const filterParamsPresent = useMemo(() => hasFilterParams(location.search), [location.search]); + const storedFilters = useMemo(() => { + if (filterParamsPresent || !filterStorageKey) return null; + return readStoredFilters(filterStorageKey); + }, [filterParamsPresent, filterStorageKey]); + const nextFilters = useMemo(() => { + if (filterParamsPresent) return parseIssueFilters(location.search); + if (storedFilters) return storedFilters; + return defaultIssuesTableFilters; + }, [filterParamsPresent, location.search, storedFilters]); + const [issueFilters, setIssueFilters] = useState(() => nextFilters); const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), @@ -138,38 +214,13 @@ export default function Issues() { ); useEffect(() => { - setIssueFilters((current) => (filtersEqual(current, parsedFilters) ? current : parsedFilters)); - }, [parsedFilters]); + setIssueFilters((current) => (filtersEqual(current, nextFilters) ? current : nextFilters)); + }, [nextFilters]); useEffect(() => { - const currentParams = new URLSearchParams(location.search); - const nextParams = new URLSearchParams(location.search); - - if (issueFilters.query) nextParams.set("q", issueFilters.query); - else nextParams.delete("q"); - - if (issueFilters.statuses.length > 0) nextParams.set("status", issueFilters.statuses.join(",")); - else nextParams.delete("status"); - - if (issueFilters.types.length > 0) nextParams.set("type", issueFilters.types.join(",")); - else nextParams.delete("type"); - - if (issueFilters.assignees.length > 0) nextParams.set("assignee", issueFilters.assignees.join(",")); - else nextParams.delete("assignee"); - - if (issueFilters.sprintId === "none") nextParams.set("sprint", "none"); - else if (issueFilters.sprintId !== "all") nextParams.set("sprint", String(issueFilters.sprintId)); - else nextParams.delete("sprint"); - - if (issueFilters.sort !== defaultIssuesTableFilters.sort) nextParams.set("sort", issueFilters.sort); - else nextParams.delete("sort"); - - if (currentParams.toString() === nextParams.toString()) return; - - const search = nextParams.toString(); - const nextUrl = `${location.pathname}${search ? `?${search}` : ""}`; - window.history.replaceState(null, "", nextUrl); - }, [issueFilters, location.pathname, location.search]); + if (!filterStorageKey) return; + writeStoredFilters(filterStorageKey, issueFilters); + }, [filterStorageKey, issueFilters]); const findById = (items: T[], id: number | null | undefined, getId: (item: T) => number) => id == null ? null : (items.find((item) => getId(item) === id) ?? null); From f469d43c8ed1568ec5c2f496feded4cd8863b6cd Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Fri, 30 Jan 2026 00:30:50 +0000 Subject: [PATCH 4/4] 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 && (
+