Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/features/carbonInventories/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export async function fetchCategoryData(
color: true,
subcategories: {
select: { id: true, name: true, icon: true },
orderBy: { name: "asc" },
orderBy: { position: "asc" },
},
},
orderBy: { position: "asc" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const cloneSubcategories = async (
icon: sub.icon,
description: sub.description,
explanation: sub.explanation,
position: sub.position,
status: sub.status,
createdById: userId,
updatedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const getMethodologyByIdService = async (
name: true,
subcategories: {
where: { status: SubcategoryStatus.ACTIVE },
orderBy: { name: "asc" },
orderBy: { position: "asc" },
select: { id: true, name: true },
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/features/methodologies/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const methodologyExportSelect = {
description: true,
subcategories: {
where: { status: SubcategoryStatus.ACTIVE },
orderBy: { name: "asc" },
orderBy: { position: "asc" },
select: {
id: true,
name: true,
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/features/subcategories/createSubcategory/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
CategoryNotFoundForSubcategoryError,
SubcategoryNameAlreadyExistsError,
SubcategoryPositionAlreadyExistsError,
} from "../errors.js";
import { getDuplicatedFieldsFromP2002Error } from "@/errors/index.js";
import { UserNotFoundError } from "../../users/errors.js";
Expand Down Expand Up @@ -38,13 +39,25 @@ export const createSubcategoryService = async (
throw new CategoryNotFoundForSubcategoryError();
}

// Positions are not supplied by the client: a new subcategory is appended
// last inside its category. DELETED rows are excluded from both the max
// and the partial unique index, so a freed position can be reused.
const { _max } = await tx.subcategory.aggregate({
where: {
categoryId: category.id,
status: { not: SubcategoryStatus.DELETED },
},
_max: { position: true },
});

const newSubcategory = await tx.subcategory.create({
data: {
categoryId: category.id,
name: data.name,
icon: data.icon,
description: data.description,
explanation: data.explanation ?? null,
position: (_max.position ?? 0) + 1,
status: SubcategoryStatus.ACTIVE,
createdById: BigInt(user.id),
updatedAt: null,
Expand All @@ -55,6 +68,7 @@ export const createSubcategoryService = async (
icon: true,
description: true,
explanation: true,
position: true,
category: {
select: { id: true, name: true, color: true },
},
Expand Down Expand Up @@ -99,6 +113,11 @@ export const createSubcategoryService = async (
if (duplicatedFields.includes("name")) {
throw new SubcategoryNameAlreadyExistsError();
}
// Two concurrent creates can compute the same next position; the unique
// index rejects the loser, which should retry instead of seeing a 500.
if (duplicatedFields.includes("position")) {
throw new SubcategoryPositionAlreadyExistsError();
}
}
}
throw error;
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/features/subcategories/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const SubcategoryNameAlreadyExistsError = createError(
409
);

export const SubcategoryPositionAlreadyExistsError = createError(
"SUBCATEGORY_POSITION_ALREADY_EXISTS",
"A subcategory with this position already exists for this category",
409
);

export const CategoryNotFoundForSubcategoryError = createError(
"CATEGORY_NOT_FOUND_FOR_SUBCATEGORY",
"Category not found",
Expand All @@ -23,3 +29,15 @@ export const CategoryFromDifferentMethodologyError = createError(
"Target category must belong to the same methodology version",
422
);

export const SameSubcategoryError = createError(
"SAME_SUBCATEGORY",
"Both subcategory IDs must be different",
422
);

export const SubcategoriesFromDifferentCategoriesError = createError(
"SUBCATEGORIES_FROM_DIFFERENT_CATEGORIES",
"Both subcategories must belong to the same category (Subcategory IDs: %s, %s)",
422
);
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const getAllSubcategoriesService = async (
},
},
},
orderBy: [{ category: { position: "asc" } }, { name: "asc" }],
orderBy: [{ category: { position: "asc" } }, { position: "asc" }],
});

return subcategories.map(
Expand All @@ -45,6 +45,7 @@ export const getAllSubcategoriesService = async (
icon: IconNameSchema.parse(subcategory.icon),
description: subcategory.description,
explanation: subcategory.explanation,
position: subcategory.position,
category: {
id: category.id.toString(),
name: category.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createPostHandler } from "@/handlerFactory/index.js";
import { swapSubcategoryPositionsService } from "./service.js";
import type {
SwapSubcategoryPositionsRequest,
SwapSubcategoryPositionsResponse,
} from "@repo/types";

export const swapSubcategoryPositionsHandler = createPostHandler<
SwapSubcategoryPositionsRequest,
SwapSubcategoryPositionsResponse
>("subcategories", swapSubcategoryPositionsService, "SubcategoryPositions");
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { defineRoute } from "@/routing/defineRoute.js";
import { swapSubcategoryPositionsHandler } from "./handler.js";
import {
SwapSubcategoryPositionsRequest,
SwapSubcategoryPositionsRequestSchema,
SwapSubcategoryPositionsResponseSchema,
} from "@repo/types";
import { ApiErrorResponseSchema } from "@/commonSchemas/errors.js";

export const swapSubcategoryPositionsRoute = defineRoute<{
Body: SwapSubcategoryPositionsRequest;
}>({
method: "POST",
path: "/swap-positions",
schema: {
tags: ["subcategories"],
summary: "Swap positions of two subcategories",
description:
"Atomically swaps the position values of two subcategories within the same category",
body: SwapSubcategoryPositionsRequestSchema,
response: {
201: SwapSubcategoryPositionsResponseSchema,
404: ApiErrorResponseSchema,
422: ApiErrorResponseSchema,
},
},
access: { mode: "private" },
handler: swapSubcategoryPositionsHandler,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { type PrismaClient } from "@repo/database";
import {
SubcategoryStatus,
User,
type SwapSubcategoryPositionsRequest,
type SwapSubcategoryPositionsResponse,
} from "@repo/types";
import {
SubcategoryNotFoundError,
SubcategoriesFromDifferentCategoriesError,
SameSubcategoryError,
} from "../errors.js";

export const swapSubcategoryPositionsService = async (
prismaClient: PrismaClient,
data: SwapSubcategoryPositionsRequest,
_user: User | null
): Promise<SwapSubcategoryPositionsResponse> => {
const idA = BigInt(data.subcategoryIdA);
const idB = BigInt(data.subcategoryIdB);

if (idA === idB) {
throw new SameSubcategoryError();
}

const [updatedA, updatedB] = await prismaClient.$transaction(async (tx) => {
const [subA, subB] = await Promise.all([
tx.subcategory.findFirst({
where: { id: idA, status: { not: SubcategoryStatus.DELETED } },
}),
tx.subcategory.findFirst({
where: { id: idB, status: { not: SubcategoryStatus.DELETED } },
}),
]);

if (!subA || !subB) {
const missingIds = [];
if (!subA) missingIds.push(idA);
if (!subB) missingIds.push(idB);
throw new SubcategoryNotFoundError(missingIds.join(", "));
}
if (subA.categoryId !== subB.categoryId) {
throw new SubcategoriesFromDifferentCategoriesError(subA.id, subB.id);
}

const positionA = subA.position;
const positionB = subB.position;
const categoryId = subA.categoryId;

// Find a safe temp position to avoid the unique constraint during the swap
const aggregate = await tx.subcategory.aggregate({
where: {
categoryId,
status: { not: SubcategoryStatus.DELETED },
},
_max: { position: true },
});
const tempPosition = (aggregate._max.position ?? 0) + 1;

// Step 1: Move A out of the way
await tx.subcategory.update({
where: { id: idA },
data: { position: tempPosition },
});
// Step 2: Move B to A's original position
const bUpdated = await tx.subcategory.update({
where: { id: idB },
data: { position: positionA },
});
// Step 3: Move A to B's original position
const aUpdated = await tx.subcategory.update({
where: { id: idA },
data: { position: positionB },
});

return [aUpdated, bUpdated] as const;
});

return {
subcategories: [
{
id: updatedA.id.toString(),
categoryId: updatedA.categoryId.toString(),
name: updatedA.name,
position: updatedA.position,
},
{
id: updatedB.id.toString(),
categoryId: updatedB.categoryId.toString(),
name: updatedB.name,
position: updatedB.position,
},
],
};
};
27 changes: 27 additions & 0 deletions apps/api/src/features/subcategories/updateSubcategory/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
SubcategoryNotFoundError,
SubcategoryNameAlreadyExistsError,
SubcategoryPositionAlreadyExistsError,
CategoryNotFoundForSubcategoryError,
CategoryFromDifferentMethodologyError,
} from "../errors.js";
Expand All @@ -36,6 +37,7 @@ export const updateSubcategoryService = async (
},
select: {
status: true,
categoryId: true,
category: { select: { methodologyVersionId: true } },
},
});
Expand All @@ -44,6 +46,11 @@ export const updateSubcategoryService = async (
throw new SubcategoryNotFoundError(id);
}

// Position within the destination category, only when the subcategory
// actually moves. Positions are unique per category, so keeping the old
// one could collide with a subcategory already sitting there.
let newPosition: number | undefined;

// Validate the target category belongs to the same methodology.
if (data.categoryId !== undefined) {
const newCategory = await tx.category.findFirst({
Expand All @@ -64,6 +71,17 @@ export const updateSubcategoryService = async (
) {
throw new CategoryFromDifferentMethodologyError();
}

if (BigInt(data.categoryId) !== targetSubcategory.categoryId) {
const { _max } = await tx.subcategory.aggregate({
where: {
categoryId: BigInt(data.categoryId),
status: { not: SubcategoryStatus.DELETED },
},
_max: { position: true },
});
newPosition = (_max.position ?? 0) + 1;
}
}

// Build update data dynamically based on provided fields
Expand All @@ -74,6 +92,8 @@ export const updateSubcategoryService = async (

if (data.categoryId !== undefined)
updateData.categoryId = BigInt(data.categoryId);
// The moved subcategory is appended last in its destination category.
if (newPosition !== undefined) updateData.position = newPosition;
if (data.name !== undefined) updateData.name = data.name;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.description !== undefined)
Expand Down Expand Up @@ -108,6 +128,7 @@ export const updateSubcategoryService = async (
icon: true,
description: true,
explanation: true,
position: true,
category: {
select: { id: true, name: true, color: true },
},
Expand All @@ -131,6 +152,7 @@ export const updateSubcategoryService = async (
icon: IconNameSchema.parse(subcategory.icon),
description: subcategory.description,
explanation: subcategory.explanation,
position: subcategory.position,
category: {
id: subcategory.category.id.toString(),
name: subcategory.category.name,
Expand All @@ -153,6 +175,11 @@ export const updateSubcategoryService = async (
if (duplicatedFields.includes("name")) {
throw new SubcategoryNameAlreadyExistsError();
}
// A concurrent move/create into the same destination category can claim
// the computed position first; surface it as a conflict, not a 500.
if (duplicatedFields.includes("position")) {
throw new SubcategoryPositionAlreadyExistsError();
}
}
}
throw error;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/api/subcategories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getAllSubcategoriesRoute } from "@/features/subcategories/getAllSubcate
import { deleteSubcategoryRoute } from "@/features/subcategories/deleteSubcategory/route.js";
import { createSubcategoryRoute } from "@/features/subcategories/createSubcategory/route.js";
import { updateSubcategoryRoute } from "@/features/subcategories/updateSubcategory/route.js";
import { swapSubcategoryPositionsRoute } from "@/features/subcategories/swapSubcategoryPositions/route.js";
import { SystemRole } from "@repo/types";

export default function subcategoriesRoutes(fastify: FastifyZodInstance) {
Expand All @@ -14,6 +15,7 @@ export default function subcategoriesRoutes(fastify: FastifyZodInstance) {
deleteSubcategoryRoute,
createSubcategoryRoute,
updateSubcategoryRoute,
swapSubcategoryPositionsRoute,
],
{ defaultSystemRoles: [SystemRole.SUPERADMIN, SystemRole.ADMIN] }
);
Expand Down
9 changes: 9 additions & 0 deletions apps/api/test/factories/subcategoryFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ export async function createTestSubcategory(
): Promise<Subcategory> {
const randomSuffix = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;

// Positions are unique per category, so default to appending last. Counting
// DELETED rows too keeps the generated position free even when a test has
// soft-deleted a sibling.
const { _max } = await prisma.subcategory.aggregate({
where: { categoryId },
_max: { position: true },
});

return await prisma.subcategory.create({
data: {
categoryId,
name: overrides?.name ?? `Test - Subcategory ${randomSuffix}`,
icon: overrides?.icon ?? "FACTORY",
description: overrides?.description ?? "Test subcategory description",
explanation: overrides?.explanation ?? null,
position: overrides?.position ?? (_max.position ?? 0) + 1,
status: overrides?.status ?? SubcategoryStatus.ACTIVE,
createdById: null,
updatedById: null,
Expand Down
Loading