diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index 88ee70d..6e58f90 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -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`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 { const Creator = aliasedTable(User, "Creator"); diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index f04d866..c56e5be 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -88,6 +88,7 @@ export async function updateOrganisation( iconURL?: string | null; statuses?: Record; features?: Record; + issueTypes?: Record; }, ) { const [organisation] = await db diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7d3066d..ef59beb 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index e00283b..03e2c9f 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -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, diff --git a/packages/backend/src/routes/issue/update.ts b/packages/backend/src/routes/issue/update.ts index e0c3c43..12d04fe 100644 --- a/packages/backend/src/routes/issue/update.ts +++ b/packages/backend/src/routes/issue/update.ts @@ -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) { diff --git a/packages/backend/src/routes/issues/replace-type.ts b/packages/backend/src/routes/issues/replace-type.ts new file mode 100644 index 0000000..a9f0fef --- /dev/null +++ b/packages/backend/src/routes/issues/replace-type.ts @@ -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); +} diff --git a/packages/backend/src/routes/issues/type-count.ts b/packages/backend/src/routes/issues/type-count.ts new file mode 100644 index 0000000..cbfd741 --- /dev/null +++ b/packages/backend/src/routes/issues/type-count.ts @@ -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); +} diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index c0924fb..6fae359 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -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); diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index 087ede1..d9c9fe2 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -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(0); const [reassignToStatus, setReassignToStatus] = useState(""); + // issue types state + type IssueTypeConfig = { icon: string; color: string }; + const [issueTypes, setIssueTypes] = useState>({}); + const [isCreatingType, setIsCreatingType] = useState(false); + const [newTypeName, setNewTypeName] = useState(""); + const [newTypeIcon, setNewTypeIcon] = useState("checkBox"); + const [newTypeColour, setNewTypeColour] = useState(DEFAULT_ISSUE_TYPES.Task.color); + const [typeError, setTypeError] = useState(null); + const [typeToRemove, setTypeToRemove] = useState(null); + const [issuesUsingType, setIssuesUsingType] = useState(0); + const [reassignToType, setReassignToType] = useState(""); + // 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; + setIssueTypes(orgIssueTypes ?? {}); } }, [selectedOrganisation]); @@ -431,6 +449,183 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { } }; + // issue types functions + const updateIssueTypes = async ( + newIssueTypes: Record, + 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( + + Created + {typeAdded.name} type successfully + , + { dismissible: false }, + ); + } else if (typeRemoved) { + toast.success( + + Removed + {typeRemoved.name} type successfully + , + { dismissible: false }, + ); + } else if (typeMoved) { + toast.success( + + Moved + {typeMoved.name} from position {typeMoved.currentIndex + 1} to {typeMoved.nextIndex + 1} + , + { dismissible: false }, + ); + } + await invalidateOrganisations(); + } catch (err) { + console.error("error updating issue types:", err); + if (typeAdded) { + toast.error( + + Error adding + {typeAdded.name} to {selectedOrganisation.Organisation.name}: {String(err)} + , + { dismissible: false }, + ); + } else if (typeRemoved) { + toast.error( + + Error removing + {typeRemoved.name} from {selectedOrganisation.Organisation.name}: {String(err)} + , + { 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( + + Error checking type usage for{" "} + + {typeName}: {String(err)} + , + { 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( + + Error removing{" "} + + {typeToRemove} from {selectedOrganisation.Organisation.name}: {String(error)} + , + { dismissible: false }, + ); + } + }; + useEffect(() => { if (!open || !selectedOrganisationId) return; void invalidateMembers(); @@ -815,6 +1010,148 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { + {/* Issue Types section */} +
+

Issue Types

+
+
+ {Object.keys(issueTypes).map((typeName, index) => { + const typeConfig = issueTypes[typeName]; + return ( +
+
+ {index + 1} + + {typeName} +
+ {isAdmin && ( + + + + + + void moveType(typeName, "up")} + className="hover:bg-primary-foreground" + > + + Move up + + void moveType(typeName, "down")} + className="hover:bg-primary-foreground" + > + + Move down + + void handleRemoveTypeClick(typeName)} + className="hover:bg-destructive/10" + > + + Remove + + + + )} +
+ ); + })} +
+ {isAdmin && + (isCreatingType ? ( + <> +
+ { + 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 + /> + + + + + void handleCreateType()} + disabled={newTypeName.trim().length > ISSUE_TYPE_MAX_LENGTH} + > + + +
+ {typeError &&

{typeError}

} + + ) : ( + + ))} +
+
+ + {/* Issue Statuses section */}

Issue Statuses

@@ -1033,6 +1370,77 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
+ + {/* Type removal dialog with reassignment */} + { + if (!open) { + setTypeToRemove(null); + setReassignToType(""); + } + }} + > + + + Remove Type + +

+ Are you sure you want to remove the{" "} + {typeToRemove && issueTypes[typeToRemove] ? ( + + + {typeToRemove} + + ) : null}{" "} + type? {issuesUsingType} issues are using it. + Which type would you like these issues to use instead? +

+ +
+ + +
+
+
diff --git a/packages/frontend/src/components/ui/icon.tsx b/packages/frontend/src/components/ui/icon.tsx index 2e4c311..098a9b8 100644 --- a/packages/frontend/src/components/ui/icon.tsx +++ b/packages/frontend/src/components/ui/icon.tsx @@ -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 ( diff --git a/packages/frontend/src/lib/query/hooks/issues.ts b/packages/frontend/src/lib/query/hooks/issues.ts index dc5508b..006f1d0 100644 --- a/packages/frontend/src/lib/query/hooks/issues.ts +++ b/packages/frontend/src/lib/query/hooks/issues.ts @@ -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({ + 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({ + mutationKey: ["issues", "replace-type"], + mutationFn: issue.replaceType, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index 41b1e7d..dff3534 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -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, diff --git a/packages/frontend/src/lib/server/issue/index.ts b/packages/frontend/src/lib/server/issue/index.ts index 169c76f..a267e19 100644 --- a/packages/frontend/src/lib/server/issue/index.ts +++ b/packages/frontend/src/lib/server/issue/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/server/issue/replaceType.ts b/packages/frontend/src/lib/server/issue/replaceType.ts new file mode 100644 index 0000000..a3a8e56 --- /dev/null +++ b/packages/frontend/src/lib/server/issue/replaceType.ts @@ -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 { + 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(); +} diff --git a/packages/frontend/src/lib/server/issue/typeCount.ts b/packages/frontend/src/lib/server/issue/typeCount.ts new file mode 100644 index 0000000..40b887b --- /dev/null +++ b/packages/frontend/src/lib/server/issue/typeCount.ts @@ -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 { + 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(); +} diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 6f2be76..14eacdd 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -116,6 +116,21 @@ export const IssuesReplaceStatusRequestSchema = z.object({ export type IssuesReplaceStatusRequest = z.infer; +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; + +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; + 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; @@ -504,6 +527,18 @@ export const ReplaceStatusResponseSchema = z.object({ export type ReplaceStatusResponse = z.infer; +export const TypeCountResponseSchema = z.object({ + count: z.number(), +}); + +export type TypeCountResponse = z.infer; + +export const ReplaceTypeResponseSchema = z.object({ + rowCount: z.number(), +}); + +export type ReplaceTypeResponse = z.infer; + // general export const SuccessResponseSchema = z.object({ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3bc57ef..f3cc78c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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,