mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Filter persistence, org export
Filter persistence, org export
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 "./email-verification";
|
||||||
|
export * from "./export";
|
||||||
export * from "./issue-comments";
|
export * from "./issue-comments";
|
||||||
export * from "./issues";
|
export * from "./issues";
|
||||||
export * from "./organisations";
|
export * from "./organisations";
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const main = async () => {
|
|||||||
|
|
||||||
"/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))),
|
"/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))),
|
||||||
"/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)),
|
"/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)),
|
||||||
|
"/organisation/export": withGlobalAuthed(withAuth(routes.organisationExport)),
|
||||||
"/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))),
|
"/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))),
|
||||||
"/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))),
|
"/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))),
|
||||||
"/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
|
"/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 organisationsByUser from "./organisation/by-user";
|
||||||
import organisationCreate from "./organisation/create";
|
import organisationCreate from "./organisation/create";
|
||||||
import organisationDelete from "./organisation/delete";
|
import organisationDelete from "./organisation/delete";
|
||||||
|
import organisationExport from "./organisation/export";
|
||||||
import organisationMemberTimeTracking from "./organisation/member-time-tracking";
|
import organisationMemberTimeTracking from "./organisation/member-time-tracking";
|
||||||
import organisationMembers from "./organisation/members";
|
import organisationMembers from "./organisation/members";
|
||||||
import organisationRemoveMember from "./organisation/remove-member";
|
import organisationRemoveMember from "./organisation/remove-member";
|
||||||
@@ -84,6 +85,7 @@ export const routes = {
|
|||||||
|
|
||||||
organisationCreate,
|
organisationCreate,
|
||||||
organisationById,
|
organisationById,
|
||||||
|
organisationExport,
|
||||||
organisationUpdate,
|
organisationUpdate,
|
||||||
organisationDelete,
|
organisationDelete,
|
||||||
organisationAddMember,
|
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()}`);
|
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 [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("info");
|
const [activeTab, setActiveTab] = useState("info");
|
||||||
|
|
||||||
@@ -885,6 +916,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2 mt-3">
|
<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)}>
|
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
|
||||||
<Icon icon="edit" className="size-4" />
|
<Icon icon="edit" className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -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<IssuesTableFilters> | 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) => {
|
const filtersEqual = (left: IssuesTableFilters, right: IssuesTableFilters) => {
|
||||||
if (left.query !== right.query) return false;
|
if (left.query !== right.query) return false;
|
||||||
if (left.sprintId !== right.sprintId) return false;
|
if (left.sprintId !== right.sprintId) return false;
|
||||||
@@ -125,8 +188,21 @@ export default function Issues() {
|
|||||||
const selectedOrganisation = useSelectedOrganisation();
|
const selectedOrganisation = useSelectedOrganisation();
|
||||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
||||||
const { data: sprintsData = [] } = useSprints(selectedProjectId);
|
const { data: sprintsData = [] } = useSprints(selectedProjectId);
|
||||||
const parsedFilters = useMemo(() => parseIssueFilters(location.search), [location.search]);
|
const filterStorageKey = useMemo(
|
||||||
const [issueFilters, setIssueFilters] = useState<IssuesTableFilters>(() => parsedFilters);
|
() => 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<IssuesTableFilters>(() => nextFilters);
|
||||||
|
|
||||||
const organisations = useMemo(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
@@ -138,38 +214,13 @@ export default function Issues() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIssueFilters((current) => (filtersEqual(current, parsedFilters) ? current : parsedFilters));
|
setIssueFilters((current) => (filtersEqual(current, nextFilters) ? current : nextFilters));
|
||||||
}, [parsedFilters]);
|
}, [nextFilters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentParams = new URLSearchParams(location.search);
|
if (!filterStorageKey) return;
|
||||||
const nextParams = new URLSearchParams(location.search);
|
writeStoredFilters(filterStorageKey, issueFilters);
|
||||||
|
}, [filterStorageKey, issueFilters]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
||||||
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
||||||
|
|||||||
@@ -331,6 +331,16 @@ export const apiContract = c.router({
|
|||||||
404: ApiErrorSchema,
|
404: ApiErrorSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
organisationExport: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/organisation/export",
|
||||||
|
query: OrgByIdQuerySchema,
|
||||||
|
responses: {
|
||||||
|
200: z.any(),
|
||||||
|
403: ApiErrorSchema,
|
||||||
|
404: ApiErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
organisationUpdate: {
|
organisationUpdate: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/organisation/update",
|
path: "/organisation/update",
|
||||||
|
|||||||
Reference in New Issue
Block a user