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);