customise organisation issue types

This commit is contained in:
2026-01-25 00:51:49 +00:00
parent d5a0829bad
commit f11c9fa826
17 changed files with 629 additions and 15 deletions

View File

@@ -145,6 +145,44 @@ export async function replaceIssueStatus(organisationId: number, oldStatus: stri
return { updated: result.rowCount ?? 0 };
}
export async function getIssueTypeCountByOrganisation(organisationId: number, type: string) {
const { Project } = await import("@sprint/shared");
const projects = await db
.select({ id: Project.id })
.from(Project)
.where(eq(Project.organisationId, organisationId));
const projectIds = projects.map((p) => p.id);
if (projectIds.length === 0) return { count: 0 };
const [result] = await db
.select({ count: sql<number>`count(*)` })
.from(Issue)
.where(and(eq(Issue.type, type), inArray(Issue.projectId, projectIds)));
return { count: result?.count ?? 0 };
}
export async function replaceIssueType(organisationId: number, oldType: string, newType: string) {
const { Project } = await import("@sprint/shared");
const projects = await db
.select({ id: Project.id })
.from(Project)
.where(eq(Project.organisationId, organisationId));
const projectIds = projects.map((p) => p.id);
if (projectIds.length === 0) return { updated: 0 };
const result = await db
.update(Issue)
.set({ type: newType })
.where(and(eq(Issue.type, oldType), inArray(Issue.projectId, projectIds)));
return { updated: result.rowCount ?? 0 };
}
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
const Creator = aliasedTable(User, "Creator");

View File

@@ -88,6 +88,7 @@ export async function updateOrganisation(
iconURL?: string | null;
statuses?: Record<string, string>;
features?: Record<string, boolean>;
issueTypes?: Record<string, { icon: string; color: string }>;
},
) {
const [organisation] = await db

View File

@@ -50,7 +50,9 @@ const main = async () => {
"/issues/by-project": withGlobal(withAuth(routes.issuesByProject)),
"/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))),
"/issues/replace-type": withGlobal(withAuth(withCSRF(routes.issuesReplaceType))),
"/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)),
"/issues/type-count": withGlobal(withAuth(routes.issuesTypeCount)),
"/issues/all": withGlobal(withAuth(routes.issues)),
"/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)),

View File

@@ -11,7 +11,9 @@ import issueCommentsByIssue from "./issue-comments/by-issue";
import issues from "./issues/all";
import issuesByProject from "./issues/by-project";
import issuesReplaceStatus from "./issues/replace-status";
import issuesReplaceType from "./issues/replace-type";
import issuesStatusCount from "./issues/status-count";
import issuesTypeCount from "./issues/type-count";
import organisationAddMember from "./organisation/add-member";
import organisationById from "./organisation/by-id";
import organisationsByUser from "./organisation/by-user";
@@ -64,7 +66,9 @@ export const routes = {
issuesByProject,
issues,
issuesReplaceStatus,
issuesReplaceType,
issuesStatusCount,
issuesTypeCount,
organisationCreate,
organisationById,

View File

@@ -48,9 +48,6 @@ export default async function issueUpdate(req: AuthedRequest) {
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return errorResponse("only organisation owners and admins can edit issues", "PERMISSION_DENIED", 403);
}
let issue: IssueRecord | undefined = existingIssue;
if (hasIssueFieldUpdates) {

View File

@@ -0,0 +1,24 @@
import { IssuesReplaceTypeRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationMemberRole, replaceIssueType } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function issuesReplaceType(req: AuthedRequest) {
const parsed = await parseJsonBody(req, IssuesReplaceTypeRequestSchema);
if ("error" in parsed) return parsed.error;
const { organisationId, oldType, newType } = parsed.data;
const membership = await getOrganisationMemberRole(organisationId, req.userId);
if (!membership) {
return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
}
if (membership.role !== "owner" && membership.role !== "admin") {
return errorResponse("only admins and owners can replace types", "PERMISSION_DENIED", 403);
}
const result = await replaceIssueType(organisationId, oldType, newType);
return Response.json(result);
}

View File

@@ -0,0 +1,21 @@
import { IssuesTypeCountQuerySchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { getIssueTypeCountByOrganisation, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
export default async function issuesTypeCount(req: AuthedRequest) {
const url = new URL(req.url);
const parsed = parseQueryParams(url, IssuesTypeCountQuerySchema);
if ("error" in parsed) return parsed.error;
const { organisationId, type } = parsed.data;
const membership = await getOrganisationMemberRole(organisationId, req.userId);
if (!membership) {
return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
}
const result = await getIssueTypeCountByOrganisation(organisationId, type);
return Response.json(result);
}

View File

@@ -7,7 +7,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
const { id, name, description, slug, iconURL, statuses, features } = parsed.data;
const { id, name, description, slug, iconURL, statuses, features, issueTypes } = parsed.data;
const existingOrganisation = await getOrganisationById(id);
if (!existingOrganisation) {
@@ -22,9 +22,9 @@ export default async function organisationUpdate(req: AuthedRequest) {
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
}
if (!name && !description && !slug && !statuses && !features && iconURL === undefined) {
if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) {
return errorResponse(
"at least one of name, description, slug, iconURL, or statuses must be provided",
"at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided",
"NO_UPDATES",
400,
);
@@ -37,6 +37,7 @@ export default async function organisationUpdate(req: AuthedRequest) {
iconURL,
statuses,
features,
issueTypes,
});
return Response.json(organisation);

View File

@@ -1,7 +1,9 @@
import {
DEFAULT_FEATURES,
DEFAULT_ISSUE_TYPES,
DEFAULT_STATUS_COLOUR,
ISSUE_STATUS_MAX_LENGTH,
ISSUE_TYPE_MAX_LENGTH,
type SprintRecord,
} from "@sprint/shared";
import { useQueryClient } from "@tanstack/react-query";
@@ -29,7 +31,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Icon from "@/components/ui/icon";
import Icon, { type IconName, iconNames } from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -43,6 +45,7 @@ import {
useProjects,
useRemoveOrganisationMember,
useReplaceIssueStatus,
useReplaceIssueType,
useSprints,
useUpdateOrganisation,
useUpdateOrganisationMemberRole,
@@ -67,6 +70,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const deleteProject = useDeleteProject();
const deleteSprint = useDeleteSprint();
const replaceIssueStatus = useReplaceIssueStatus();
const replaceIssueType = useReplaceIssueType();
const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
@@ -122,6 +126,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
const [reassignToStatus, setReassignToStatus] = useState<string>("");
// issue types state
type IssueTypeConfig = { icon: string; color: string };
const [issueTypes, setIssueTypes] = useState<Record<string, IssueTypeConfig>>({});
const [isCreatingType, setIsCreatingType] = useState(false);
const [newTypeName, setNewTypeName] = useState("");
const [newTypeIcon, setNewTypeIcon] = useState<IconName>("checkBox");
const [newTypeColour, setNewTypeColour] = useState(DEFAULT_ISSUE_TYPES.Task.color);
const [typeError, setTypeError] = useState<string | null>(null);
const [typeToRemove, setTypeToRemove] = useState<string | null>(null);
const [issuesUsingType, setIssuesUsingType] = useState<number>(0);
const [reassignToType, setReassignToType] = useState<string>("");
// edit/delete state for organisations, projects, and sprints
const [editOrgOpen, setEditOrgOpen] = useState(false);
const [editProjectOpen, setEditProjectOpen] = useState(false);
@@ -239,6 +255,8 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
useEffect(() => {
if (selectedOrganisation) {
setStatuses(selectedOrganisation.Organisation.statuses);
const orgIssueTypes = selectedOrganisation.Organisation.issueTypes as Record<string, IssueTypeConfig>;
setIssueTypes(orgIssueTypes ?? {});
}
}, [selectedOrganisation]);
@@ -431,6 +449,183 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
}
};
// issue types functions
const updateIssueTypes = async (
newIssueTypes: Record<string, IssueTypeConfig>,
typeRemoved?: { name: string; icon: string; color: string },
typeAdded?: { name: string; icon: string; color: string },
typeMoved?: { name: string; icon: string; color: string; currentIndex: number; nextIndex: number },
) => {
if (!selectedOrganisation) return;
try {
await updateOrganisation.mutateAsync({
id: selectedOrganisation.Organisation.id,
issueTypes: newIssueTypes,
});
setIssueTypes(newIssueTypes);
if (typeAdded) {
toast.success(
<span className="inline-flex items-center gap-1.5">
Created <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
{typeAdded.name} type successfully
</span>,
{ dismissible: false },
);
} else if (typeRemoved) {
toast.success(
<span className="inline-flex items-center gap-1.5">
Removed <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
{typeRemoved.name} type successfully
</span>,
{ dismissible: false },
);
} else if (typeMoved) {
toast.success(
<span className="inline-flex items-center gap-1.5">
Moved <Icon icon={typeMoved.icon as IconName} size={14} color={typeMoved.color} />
{typeMoved.name} from position {typeMoved.currentIndex + 1} to {typeMoved.nextIndex + 1}
</span>,
{ dismissible: false },
);
}
await invalidateOrganisations();
} catch (err) {
console.error("error updating issue types:", err);
if (typeAdded) {
toast.error(
<span className="inline-flex items-center gap-1.5">
Error adding <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
{typeAdded.name} to {selectedOrganisation.Organisation.name}: {String(err)}
</span>,
{ dismissible: false },
);
} else if (typeRemoved) {
toast.error(
<span className="inline-flex items-center gap-1.5">
Error removing <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
{typeRemoved.name} from {selectedOrganisation.Organisation.name}: {String(err)}
</span>,
{ dismissible: false },
);
}
}
};
const handleCreateType = async () => {
const trimmed = newTypeName.trim();
if (!trimmed) return;
if (trimmed.length > ISSUE_TYPE_MAX_LENGTH) {
setTypeError(`type name must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`);
return;
}
if (Object.keys(issueTypes).includes(trimmed)) {
setNewTypeName("");
setIsCreatingType(false);
setTypeError(null);
return;
}
const newIssueTypes = { ...issueTypes };
newIssueTypes[trimmed] = { icon: newTypeIcon, color: newTypeColour };
await updateIssueTypes(newIssueTypes, undefined, {
name: trimmed,
icon: newTypeIcon,
color: newTypeColour,
});
setNewTypeName("");
setNewTypeIcon("checkBox");
setNewTypeColour(DEFAULT_ISSUE_TYPES.Task.color);
setIsCreatingType(false);
setTypeError(null);
};
const handleRemoveTypeClick = async (typeName: string) => {
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
try {
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName);
const count = data.count ?? 0;
if (count > 0) {
setTypeToRemove(typeName);
setIssuesUsingType(count);
const remaining = Object.keys(issueTypes).filter((t) => t !== typeName);
setReassignToType(remaining[0] || "");
return;
}
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeName);
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
name: typeName,
...issueTypes[typeName],
});
} catch (err) {
console.error("error checking type usage:", err);
toast.error(
<span className="inline-flex items-center gap-1.5">
Error checking type usage for{" "}
<Icon icon={issueTypes[typeName].icon as IconName} size={14} color={issueTypes[typeName].color} />
{typeName}: {String(err)}
</span>,
{ dismissible: false },
);
}
};
const moveType = async (typeName: string, direction: "up" | "down") => {
const keys = Object.keys(issueTypes);
const currentIndex = keys.indexOf(typeName);
if (currentIndex === -1) return;
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= keys.length) return;
const nextKeys = [...keys];
[nextKeys[currentIndex], nextKeys[nextIndex]] = [nextKeys[nextIndex], nextKeys[currentIndex]];
await updateIssueTypes(
Object.fromEntries(nextKeys.map((t) => [t, issueTypes[t]])),
undefined,
undefined,
{ name: typeName, ...issueTypes[typeName], currentIndex, nextIndex },
);
};
const confirmRemoveType = async () => {
if (!typeToRemove || !reassignToType || !selectedOrganisation) return;
try {
await replaceIssueType.mutateAsync({
organisationId: selectedOrganisation.Organisation.id,
oldType: typeToRemove,
newType: reassignToType,
});
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeToRemove);
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
name: typeToRemove,
...issueTypes[typeToRemove],
});
setTypeToRemove(null);
setReassignToType("");
} catch (error) {
console.error("error replacing type:", error);
toast.error(
<span className="inline-flex items-center gap-1.5">
Error removing{" "}
<Icon
icon={issueTypes[typeToRemove].icon as IconName}
size={14}
color={issueTypes[typeToRemove].color}
/>
{typeToRemove} from {selectedOrganisation.Organisation.name}: {String(error)}
</span>,
{ dismissible: false },
);
}
};
useEffect(() => {
if (!open || !selectedOrganisationId) return;
void invalidateMembers();
@@ -815,6 +1010,148 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</TabsContent>
<TabsContent value="issues">
{/* Issue Types section */}
<div className="border p-2 min-w-0 overflow-hidden mb-2">
<h2 className="text-xl font-600 mb-2">Issue Types</h2>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-col gap-2 max-h-86 overflow-y-scroll grid grid-cols-2">
{Object.keys(issueTypes).map((typeName, index) => {
const typeConfig = issueTypes[typeName];
return (
<div key={typeName} className="flex items-center justify-between p-2 border">
<div className="flex items-center gap-2">
<span className="text-sm tabular-nums">{index + 1}</span>
<Icon icon={typeConfig.icon as IconName} size={16} color={typeConfig.color} />
<span className="text-sm">{typeName}</span>
</div>
{isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger
asChild
size={"sm"}
noStyle
className="hover:opacity-80 cursor-pointer"
>
<Icon icon="ellipsisVertical" className="size-4 text-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4} className="bg-background">
<DropdownMenuItem
disabled={index === 0}
onSelect={() => void moveType(typeName, "up")}
className="hover:bg-primary-foreground"
>
<Icon icon="chevronUp" className="size-4 text-muted-foreground" />
Move up
</DropdownMenuItem>
<DropdownMenuItem
disabled={index === Object.keys(issueTypes).length - 1}
onSelect={() => void moveType(typeName, "down")}
className="hover:bg-primary-foreground"
>
<Icon icon="chevronDown" className="size-4 text-muted-foreground" />
Move down
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={Object.keys(issueTypes).length <= 1}
onSelect={() => void handleRemoveTypeClick(typeName)}
className="hover:bg-destructive/10"
>
<Icon icon="x" className="size-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})}
</div>
{isAdmin &&
(isCreatingType ? (
<>
<div className="flex gap-2 w-full min-w-0">
<Input
value={newTypeName}
maxLength={ISSUE_TYPE_MAX_LENGTH}
onChange={(e) => {
setNewTypeName(e.target.value);
if (typeError) setTypeError(null);
}}
placeholder="Type name"
className="flex-1 w-0 min-w-0"
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleCreateType();
} else if (e.key === "Escape") {
setIsCreatingType(false);
setNewTypeName("");
setTypeError(null);
}
}}
autoFocus
/>
<Select value={newTypeIcon} onValueChange={(v) => setNewTypeIcon(v as IconName)}>
<SelectTrigger
className="group flex items-center w-min"
variant="default"
chevronClassName="hidden"
>
<Icon icon={newTypeIcon} size={20} color={newTypeColour} />
</SelectTrigger>
<SelectContent
side="bottom"
position="popper"
align="start"
className="max-h-64"
>
{iconNames.map((iconName) => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2">
<Icon icon={iconName} size={16} color={newTypeColour} />
<span className="text-xs">
{unCamelCase(iconName).replace(" Icon", "").replace("2Icon", " 2")}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<ColourPicker
colour={newTypeColour}
onChange={setNewTypeColour}
asChild={false}
className="w-9 h-9"
/>
<IconButton
variant="outline"
size="md"
onClick={() => void handleCreateType()}
disabled={newTypeName.trim().length > ISSUE_TYPE_MAX_LENGTH}
>
<Icon icon="plus" className="size-4" />
</IconButton>
</div>
{typeError && <p className="text-xs text-destructive">{typeError}</p>}
</>
) : (
<Button
variant="outline"
onClick={() => {
setIsCreatingType(true);
setTypeError(null);
}}
className="flex gap-2 w-full min-w-0"
>
Create type <Icon icon="plus" className="size-4" />
</Button>
))}
</div>
</div>
{/* Issue Statuses section */}
<div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2">Issue Statuses</h2>
<div className="flex flex-col gap-2 w-full">
@@ -1033,6 +1370,77 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</div>
</DialogContent>
</Dialog>
{/* Type removal dialog with reassignment */}
<Dialog
open={typeToRemove !== null}
onOpenChange={(open) => {
if (!open) {
setTypeToRemove(null);
setReassignToType("");
}
}}
>
<DialogContent className="w-md">
<DialogHeader>
<DialogTitle>Remove Type</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove the{" "}
{typeToRemove && issueTypes[typeToRemove] ? (
<span className="inline-flex items-center gap-1">
<Icon
icon={issueTypes[typeToRemove].icon as IconName}
size={14}
color={issueTypes[typeToRemove].color}
/>
{typeToRemove}
</span>
) : null}{" "}
type? <span className="font-700 text-foreground">{issuesUsingType}</span> issues are using it.
Which type would you like these issues to use instead?
</p>
<Select value={reassignToType} onValueChange={setReassignToType}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent side={"bottom"} position="popper" align="start">
{Object.keys(issueTypes)
.filter((t) => t !== typeToRemove)
.map((typeName) => (
<SelectItem key={typeName} value={typeName}>
<span className="inline-flex items-center gap-2">
<Icon
icon={issueTypes[typeName].icon as IconName}
size={14}
color={issueTypes[typeName].color}
/>
{typeName}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2 justify-end mt-4">
<Button
variant="outline"
onClick={() => {
setTypeToRemove(null);
setReassignToType("");
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmRemoveType()}
disabled={!reassignToType}
>
Remove
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</DialogContent>
</Dialog>

View File

@@ -181,17 +181,20 @@ export default function Icon({
return null;
}
let fill = "transparent";
// lucide fills sillily
if (color && resolvedStyle !== "lucide") {
fill = color;
} else if (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) {
fill = "var(--foreground)";
} else if (resolvedStyle === "phosphor") {
fill = "var(--foreground)";
}
return (
<IconComponent
size={size}
fill={
color
? color
: (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) ||
resolvedStyle === "phosphor"
? "var(--foreground)"
: "transparent"
}
fill={fill}
style={{ color: color ? color : "var(--foreground)" }}
{...props}
/>

View File

@@ -3,9 +3,11 @@ import type {
IssueRecord,
IssueResponse,
IssuesReplaceStatusRequest,
IssuesReplaceTypeRequest,
IssueUpdateRequest,
StatusCountResponse,
SuccessResponse,
TypeCountResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
@@ -76,3 +78,23 @@ export function useReplaceIssueStatus() {
},
});
}
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
return useQuery<TypeCountResponse>({
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""),
queryFn: () => issue.typeCount(organisationId ?? 0, type ?? ""),
enabled: Boolean(organisationId && type),
});
}
export function useReplaceIssueType() {
const queryClient = useQueryClient();
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
mutationKey: ["issues", "replace-type"],
mutationFn: issue.replaceType,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
});
}

View File

@@ -15,6 +15,8 @@ export const queryKeys = {
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
typeCount: (organisationId: number, type: string) =>
[...queryKeys.issues.all, "type-count", organisationId, type] as const,
},
issueComments: {
all: ["issue-comments"] as const,

View File

@@ -2,5 +2,7 @@ export { byProject } from "@/lib/server/issue/byProject";
export { create } from "@/lib/server/issue/create";
export { remove as delete } from "@/lib/server/issue/delete";
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
export { replaceType } from "@/lib/server/issue/replaceType";
export { statusCount } from "@/lib/server/issue/statusCount";
export { typeCount } from "@/lib/server/issue/typeCount";
export { update } from "@/lib/server/issue/update";

View File

@@ -0,0 +1,24 @@
import type { IssuesReplaceTypeRequest, ReplaceTypeResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function replaceType(request: IssuesReplaceTypeRequest): Promise<ReplaceTypeResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issues/replace-type`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to replace type (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -0,0 +1,20 @@
import type { TypeCountResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function typeCount(organisationId: number, type: string): Promise<TypeCountResponse> {
const url = new URL(`${getServerURL()}/issues/type-count`);
url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("type", type);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue type count (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -116,6 +116,21 @@ export const IssuesReplaceStatusRequestSchema = z.object({
export type IssuesReplaceStatusRequest = z.infer<typeof IssuesReplaceStatusRequestSchema>;
export const IssuesTypeCountQuerySchema = z.object({
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
type: z.string().min(1, "Type is required").max(ISSUE_TYPE_MAX_LENGTH),
});
export type IssuesTypeCountQuery = z.infer<typeof IssuesTypeCountQuerySchema>;
export const IssuesReplaceTypeRequestSchema = z.object({
organisationId: z.number().int().positive("organisationId must be a positive integer"),
oldType: z.string().min(1, "oldType is required").max(ISSUE_TYPE_MAX_LENGTH),
newType: z.string().min(1, "newType is required").max(ISSUE_TYPE_MAX_LENGTH),
});
export type IssuesReplaceTypeRequest = z.infer<typeof IssuesReplaceTypeRequestSchema>;
export const IssueCommentCreateRequestSchema = z.object({
issueId: z.number().int().positive("issueId must be a positive integer"),
body: z.string().min(1, "Comment is required").max(ISSUE_COMMENT_MAX_LENGTH),
@@ -175,6 +190,14 @@ export const OrgUpdateRequestSchema = z.object({
"Features must include all default features",
)
.optional(),
issueTypes: z
.record(z.object({ icon: z.string(), color: z.string() }))
.refine((obj) => Object.keys(obj).length > 0, "Issue types must have at least one entry")
.refine(
(obj) => Object.keys(obj).every((key) => key.length <= ISSUE_TYPE_MAX_LENGTH),
`Issue type keys must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`,
)
.optional(),
});
export type OrgUpdateRequest = z.infer<typeof OrgUpdateRequestSchema>;
@@ -504,6 +527,18 @@ export const ReplaceStatusResponseSchema = z.object({
export type ReplaceStatusResponse = z.infer<typeof ReplaceStatusResponseSchema>;
export const TypeCountResponseSchema = z.object({
count: z.number(),
});
export type TypeCountResponse = z.infer<typeof TypeCountResponseSchema>;
export const ReplaceTypeResponseSchema = z.object({
rowCount: z.number(),
});
export type ReplaceTypeResponse = z.infer<typeof ReplaceTypeResponseSchema>;
// general
export const SuccessResponseSchema = z.object({

View File

@@ -10,7 +10,9 @@ export type {
IssueResponseType,
IssuesByProjectQuery,
IssuesReplaceStatusRequest,
IssuesReplaceTypeRequest,
IssuesStatusCountQuery,
IssuesTypeCountQuery,
IssueUpdateRequest,
LoginRequest,
OrgAddMemberRequest,
@@ -31,6 +33,7 @@ export type {
ProjectUpdateRequest,
RegisterRequest,
ReplaceStatusResponse,
ReplaceTypeResponse,
SprintCreateRequest,
SprintDeleteRequest,
SprintResponseType,
@@ -42,6 +45,7 @@ export type {
TimerGetQuery,
TimerStateType,
TimerToggleRequest,
TypeCountResponse,
UserByUsernameQuery,
UserResponse,
UserUpdateRequest,
@@ -61,7 +65,9 @@ export {
IssueResponseSchema,
IssuesByProjectQuerySchema,
IssuesReplaceStatusRequestSchema,
IssuesReplaceTypeRequestSchema,
IssuesStatusCountQuerySchema,
IssuesTypeCountQuerySchema,
IssueUpdateRequestSchema,
LoginRequestSchema,
OrgAddMemberRequestSchema,
@@ -85,6 +91,7 @@ export {
ProjectUpdateRequestSchema,
RegisterRequestSchema,
ReplaceStatusResponseSchema,
ReplaceTypeResponseSchema,
SprintCreateRequestSchema,
SprintDeleteRequestSchema,
SprintRecordSchema,
@@ -96,6 +103,7 @@ export {
TimerGetQuerySchema,
TimerStateSchema,
TimerToggleRequestSchema,
TypeCountResponseSchema,
UserByUsernameQuerySchema,
UserResponseSchema,
UserUpdateRequestSchema,
@@ -105,6 +113,7 @@ export {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH,
ISSUE_TYPE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH,
@@ -145,6 +154,7 @@ export type {
} from "./schema";
export {
DEFAULT_FEATURES,
DEFAULT_ISSUE_TYPES,
DEFAULT_SPRINT_COLOUR,
DEFAULT_STATUS_COLOUR,
DEFAULT_STATUS_COLOURS,