Files
kan/packages/db/src/repository/checklist.repo.ts
Eliott Herbert-Byrnes 3cb40d0f9a feat: implement drag-and-drop reordering for checklist items (#275)
* feat: implement drag-and-drop reordering for checklist items

* WIP: Changes before syncing with main

* feat: update drag icon and positioning

* refactor: consolidate checklist item updates into single endpoint

- Remove standalone reorderItem route (now part of updateItem)
- Add optional index parameter to updateItem for reordering
- Change updateItem from PUT to PATCH method
- Add deletedAt IS NULL filter to reorderItem SQL queries
- Follows existing pattern from card.update route

* feat: add optimistic updates

* chore: translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-15 10:01:04 +00:00

425 lines
10 KiB
TypeScript

import { and, desc, eq, isNull, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { checklistItems, checklists } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const create = async (
db: dbClient,
checklistInput: {
cardId: number;
name: string;
createdBy: string;
},
) => {
return db.transaction(async (tx) => {
const card = await tx.query.checklists.findFirst({
where: and(
eq(checklists.cardId, checklistInput.cardId),
isNull(checklists.deletedAt),
),
orderBy: desc(checklists.index),
});
const [result] = await tx
.insert(checklists)
.values({
publicId: generateUID(),
name: checklistInput.name,
createdBy: checklistInput.createdBy,
cardId: checklistInput.cardId,
index: card ? card.index + 1 : 0,
})
.returning({
id: checklists.id,
publicId: checklists.publicId,
name: checklists.name,
});
return result;
});
};
export const createItem = async (
db: dbClient,
checklistItemInput: {
checklistId: number;
title: string;
createdBy: string;
completed?: boolean;
},
) => {
return db.transaction(async (tx) => {
const lastItem = await tx.query.checklistItems.findFirst({
where: and(
eq(checklistItems.checklistId, checklistItemInput.checklistId),
isNull(checklistItems.deletedAt),
),
orderBy: desc(checklistItems.index),
});
const [result] = await tx
.insert(checklistItems)
.values({
publicId: generateUID(),
title: checklistItemInput.title,
createdBy: checklistItemInput.createdBy,
checklistId: checklistItemInput.checklistId,
index: lastItem ? lastItem.index + 1 : 0,
completed: checklistItemInput.completed ?? false,
})
.returning({
id: checklistItems.id,
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
});
};
export const getChecklistByPublicId = async (
db: dbClient,
checklistPublicId: string,
) => {
const checklist = await db.query.checklists.findFirst({
where: and(
eq(checklists.publicId, checklistPublicId),
isNull(checklists.deletedAt),
),
with: {
card: {
with: {
list: {
with: {
board: {
with: {
workspace: true,
},
},
},
},
},
},
},
});
return checklist;
};
export const getChecklistItemByPublicIdWithChecklist = async (
db: dbClient,
checklistItemPublicId: string,
) => {
const item = await db.query.checklistItems.findFirst({
where: and(
eq(checklistItems.publicId, checklistItemPublicId),
isNull(checklistItems.deletedAt),
),
with: {
checklist: {
with: {
card: {
with: {
list: {
with: {
board: {
with: { workspace: true },
},
},
},
},
},
},
},
},
});
return item;
};
export const updateItemById = async (
db: dbClient,
args: { id: number; title?: string; completed?: boolean },
) => {
const [result] = await db
.update(checklistItems)
.set({
...(args.title !== undefined ? { title: args.title } : {}),
...(args.completed !== undefined ? { completed: args.completed } : {}),
updatedAt: new Date(),
})
.where(eq(checklistItems.id, args.id))
.returning({
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
};
export const softDeleteItemById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklistItems.id, args.id))
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteAllItemsByChecklistId = async (
db: dbClient,
args: { checklistId: number; deletedAt: Date; deletedBy: string },
) => {
const result = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(
and(
eq(checklistItems.checklistId, args.checklistId),
isNull(checklistItems.deletedAt),
),
)
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklists)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklists.id, args.id))
.returning({ id: checklists.id });
return result;
};
export const updateChecklistById = async (
db: dbClient,
args: { id: number; name: string },
) => {
const [result] = await db
.update(checklists)
.set({ name: args.name, updatedAt: new Date() })
.where(eq(checklists.id, args.id))
.returning({ publicId: checklists.publicId, name: checklists.name });
return result;
};
export const bulkCreate = async (
db: dbClient,
checklistInput: {
cardId: number;
name: string;
createdBy: string;
index: number;
}[],
) => {
if (checklistInput.length === 0) return [];
return db.transaction(async (tx) => {
const byCard = groupByKey(checklistInput, "cardId");
const allValuesToInsert: {
publicId: string;
cardId: number;
name: string;
createdBy: string;
index: number;
}[] = [];
for (const [cardId, items] of byCard.entries()) {
const last = await tx.query.checklists.findFirst({
columns: { index: true },
where: and(eq(checklists.cardId, cardId), isNull(checklists.deletedAt)),
orderBy: [desc(checklists.index)],
});
let nextIndex = last ? last.index + 1 : 0;
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const item of sorted) {
allValuesToInsert.push({
publicId: generateUID(),
...item,
index: nextIndex++,
});
}
}
const inserted = await tx
.insert(checklists)
.values(allValuesToInsert)
.returning({ id: checklists.id, publicId: checklists.publicId });
return inserted;
});
};
export const bulkCreateItems = async (
db: dbClient,
checklistItemInput: {
checklistId: number;
title: string;
createdBy: string;
index: number;
completed: boolean;
}[],
) => {
if (checklistItemInput.length === 0) return [];
return db.transaction(async (tx) => {
const byChecklist = groupByKey(checklistItemInput, "checklistId");
const allValuesToInsert: {
publicId: string;
checklistId: number;
title: string;
createdBy: string;
index: number;
completed: boolean;
}[] = [];
for (const [checklistId, items] of byChecklist.entries()) {
const last = await tx.query.checklistItems.findFirst({
columns: { index: true },
where: and(
eq(checklistItems.checklistId, checklistId),
isNull(checklistItems.deletedAt),
),
orderBy: [desc(checklistItems.index)],
});
let nextIndex = last ? last.index + 1 : 0;
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const item of sorted) {
allValuesToInsert.push({
publicId: generateUID(),
...item,
index: nextIndex++,
});
}
}
const inserted = await tx
.insert(checklistItems)
.values(allValuesToInsert)
.returning({
id: checklistItems.id,
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return inserted;
});
};
const groupByKey = <T extends Record<string, unknown>>(
items: T[],
keyField: keyof T,
): Map<number, T[]> => {
const grouped = new Map<number, T[]>();
for (const item of items) {
const key = item[keyField] as number;
const arr = grouped.get(key) ?? [];
arr.push(item);
grouped.set(key, arr);
}
return grouped;
};
export const reorderItem = async (
db: dbClient,
args: {
itemId: number;
newIndex: number;
},
) => {
return db.transaction(async (tx) => {
const item = await tx.query.checklistItems.findFirst({
columns: {
id: true,
index: true,
checklistId: true,
},
where: and(eq(checklistItems.id, args.itemId), isNull(checklistItems.deletedAt)),
});
if (!item) {
throw new Error(`Checklist item not found for ID ${args.itemId}`);
}
const currentIndex = item.index;
const newIndex = args.newIndex;
if (currentIndex === newIndex) {
const unchanged = await tx.query.checklistItems.findFirst({
columns: {
publicId: true,
title: true,
completed: true,
},
where: and(eq(checklistItems.id, args.itemId), isNull(checklistItems.deletedAt)),
});
if (!unchanged) {
throw new Error(`Checklist item not found for ID ${args.itemId}`);
}
return unchanged;
}
if (currentIndex < newIndex) {
await tx.execute(sql`
UPDATE card_checklist_item
SET index = index - 1
WHERE "checklistId" = ${item.checklistId}
AND index > ${currentIndex}
AND index <= ${newIndex}
AND "deletedAt" IS NULL
`);
} else {
await tx.execute(sql`
UPDATE card_checklist_item
SET index = index + 1
WHERE "checklistId" = ${item.checklistId}
AND index >= ${newIndex}
AND index < ${currentIndex}
AND "deletedAt" IS NULL
`);
}
const [updated] = await tx
.update(checklistItems)
.set({ index: newIndex })
.where(eq(checklistItems.id, args.itemId))
.returning({
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
if (!updated) {
throw new Error(`Failed to update checklist item with ID ${args.itemId}`);
}
return updated;
});
}