From 7373cfa4688bbb905c076e4a40e560948ca9b387 Mon Sep 17 00:00:00 2001 From: Aidan Kelly Date: Thu, 30 Apr 2026 00:39:34 -0400 Subject: [PATCH] All added features working --- server/src/data/db/schema.ts | 2 + server/src/data/repository/knowledge-repo.ts | 101 ++++- server/src/routers/knowledge.ts | 38 ++ server/src/service/knowledge-service.ts | 10 + server/src/types/knowledge-types.ts | 8 + web/src/app/admin/page.tsx | 2 +- web/src/app/knowledge/page.tsx | 393 +++++++++++++++++-- web/src/components/icons/index.tsx | 2 + 8 files changed, 511 insertions(+), 45 deletions(-) diff --git a/server/src/data/db/schema.ts b/server/src/data/db/schema.ts index 4f498a17..f8852fb3 100644 --- a/server/src/data/db/schema.ts +++ b/server/src/data/db/schema.ts @@ -721,6 +721,7 @@ export const knowledgeFolders = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }, (table) => [ index("ix_knowledge_folders_parent_folder_id").on(table.parentFolderId), @@ -748,6 +749,7 @@ export const knowledgeItems = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }, (table) => [ index("ix_knowledge_items_folder_id").on(table.folderId), diff --git a/server/src/data/repository/knowledge-repo.ts b/server/src/data/repository/knowledge-repo.ts index beb69561..bb4f5749 100644 --- a/server/src/data/repository/knowledge-repo.ts +++ b/server/src/data/repository/knowledge-repo.ts @@ -1,4 +1,4 @@ -import { asc, eq, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { files, knowledgeAttachments, @@ -41,7 +41,12 @@ export class KnowledgeRepository { return await db .select() .from(knowledgeFolders) - .where(isNull(knowledgeFolders.parentFolderId)) + .where( + and( + isNull(knowledgeFolders.parentFolderId), + isNull(knowledgeFolders.deletedAt), + ), + ) .orderBy(asc(knowledgeFolders.title)); } @@ -59,7 +64,12 @@ export class KnowledgeRepository { const [folder] = await db .select() .from(knowledgeFolders) - .where(eq(knowledgeFolders.folderId, cursor)) + .where( + and( + eq(knowledgeFolders.folderId, cursor), + isNull(knowledgeFolders.deletedAt), + ), + ) .limit(1); if (!folder) break; chain.push(folder); @@ -76,7 +86,12 @@ export class KnowledgeRepository { return await db .select() .from(knowledgeFolders) - .where(eq(knowledgeFolders.parentFolderId, parentFolderId)) + .where( + and( + eq(knowledgeFolders.parentFolderId, parentFolderId), + isNull(knowledgeFolders.deletedAt), + ), + ) .orderBy(asc(knowledgeFolders.title)); } @@ -87,7 +102,12 @@ export class KnowledgeRepository { return await db .select() .from(knowledgeItems) - .where(eq(knowledgeItems.folderId, folderId)) + .where( + and( + eq(knowledgeItems.folderId, folderId), + isNull(knowledgeItems.deletedAt), + ), + ) .orderBy(asc(knowledgeItems.name)); } @@ -99,7 +119,12 @@ export class KnowledgeRepository { const [item] = await db .select() .from(knowledgeItems) - .where(eq(knowledgeItems.itemId, itemId)) + .where( + and( + eq(knowledgeItems.itemId, itemId), + isNull(knowledgeItems.deletedAt), + ), + ) .limit(1); if (!item) { @@ -342,11 +367,66 @@ export class KnowledgeRepository { return deleted?.fileId ?? null; } + async deleteFolder(folderId: string) { + await this.ensureFolderExists(folderId); + + const allFolderIds: string[] = [folderId]; + const queue: string[] = [folderId]; + + while (queue.length > 0) { + const parentId = queue.shift()!; + const children = await db + .select({ folderId: knowledgeFolders.folderId }) + .from(knowledgeFolders) + .where( + and( + eq(knowledgeFolders.parentFolderId, parentId), + isNull(knowledgeFolders.deletedAt), + ), + ); + for (const child of children) { + allFolderIds.push(child.folderId); + queue.push(child.folderId); + } + } + + const now = new Date(); + await db.transaction(async (tx) => { + await tx + .update(knowledgeItems) + .set({ deletedAt: now }) + .where( + and( + inArray(knowledgeItems.folderId, allFolderIds), + isNull(knowledgeItems.deletedAt), + ), + ); + await tx + .update(knowledgeFolders) + .set({ deletedAt: now }) + .where(inArray(knowledgeFolders.folderId, allFolderIds)); + }); + } + + async deleteItem(itemId: string) { + await this.ensureItemExists(itemId); + + await db + .update(knowledgeItems) + .set({ deletedAt: new Date() }) + .where(eq(knowledgeItems.itemId, itemId)); + } + private async ensureFolderExists(folderId: string): Promise { const [folder] = await db .select({ folderId: knowledgeFolders.folderId }) .from(knowledgeFolders) - .where(eq(knowledgeFolders.folderId, folderId)) + .where( + and( + eq(knowledgeFolders.folderId, folderId), + isNull(knowledgeFolders.deletedAt), + ), + ) .limit(1); if (!folder) { @@ -358,7 +438,12 @@ export class KnowledgeRepository { const [item] = await db .select({ itemId: knowledgeItems.itemId }) .from(knowledgeItems) - .where(eq(knowledgeItems.itemId, itemId)) + .where( + and( + eq(knowledgeItems.itemId, itemId), + isNull(knowledgeItems.deletedAt), + ), + ) .limit(1); if (!item) { diff --git a/server/src/routers/knowledge.ts b/server/src/routers/knowledge.ts index eb157fb0..2a0639d0 100644 --- a/server/src/routers/knowledge.ts +++ b/server/src/routers/knowledge.ts @@ -9,7 +9,9 @@ import { createFolderInputSchema, createItemAttachmentInputSchema, createItemInputSchema, + deleteFolderInputSchema, deleteItemAttachmentInputSchema, + deleteItemInputSchema, getFolderAncestorsInputSchema, getFoldersInFolderInputSchema, getItemAttachmentInputSchema, @@ -261,6 +263,40 @@ const replaceItemAttachment = protectedProcedure }), ); +const deleteFolder = protectedProcedure + .input(deleteFolderInputSchema) + .output(simpleOkSchema) + .meta({ + openapi: { + method: "POST", + path: "/knowledge.deleteFolder", + summary: "Soft-delete a folder and all its contents", + tags: ["Knowledge"], + }, + }) + .mutation(({ input }) => + withErrorHandling("deleteFolder", async () => { + return await knowledgeService.deleteFolder(input.folderId); + }), + ); + +const deleteItem = protectedProcedure + .input(deleteItemInputSchema) + .output(simpleOkSchema) + .meta({ + openapi: { + method: "POST", + path: "/knowledge.deleteItem", + summary: "Soft-delete a knowledge item", + tags: ["Knowledge"], + }, + }) + .mutation(({ input }) => + withErrorHandling("deleteItem", async () => { + return await knowledgeService.deleteItem(input.itemId); + }), + ); + const deleteItemAttachment = protectedProcedure .input(deleteItemAttachmentInputSchema) .output(simpleOkSchema) @@ -292,5 +328,7 @@ export const knowledgeRouter = router({ updateItem, createItemAttachment, replaceItemAttachment, + deleteFolder, + deleteItem, deleteItemAttachment, }); diff --git a/server/src/service/knowledge-service.ts b/server/src/service/knowledge-service.ts index 66bb1cc7..0befcea9 100644 --- a/server/src/service/knowledge-service.ts +++ b/server/src/service/knowledge-service.ts @@ -103,6 +103,16 @@ export class KnowledgeService { return attachment; } + async deleteFolder(folderId: string) { + await this.knowledgeRepo.deleteFolder(folderId); + return { ok: true }; + } + + async deleteItem(itemId: string) { + await this.knowledgeRepo.deleteItem(itemId); + return { ok: true }; + } + async deleteItemAttachment(itemId: string) { const oldFileId = await this.knowledgeRepo.deleteItemAttachment(itemId); diff --git a/server/src/types/knowledge-types.ts b/server/src/types/knowledge-types.ts index 53f2792c..0bf305dd 100644 --- a/server/src/types/knowledge-types.ts +++ b/server/src/types/knowledge-types.ts @@ -99,4 +99,12 @@ export const deleteItemAttachmentInputSchema = z.object({ itemId: uuidSchema, }); +export const deleteFolderInputSchema = z.object({ + folderId: uuidSchema, +}); + +export const deleteItemInputSchema = z.object({ + itemId: uuidSchema, +}); + export const simpleOkSchema = z.object({ ok: z.boolean() }); diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx index 4e385a13..094c66e5 100644 --- a/web/src/app/admin/page.tsx +++ b/web/src/app/admin/page.tsx @@ -1,7 +1,7 @@ "use client"; import type { RoleKey } from "@server/data/roles"; -import { BarChart2, ShieldCheck, UserPlus, Users } from "lucide-react"; +import { BarChart2, ShieldCheck, UserPlus } from "lucide-react"; import type { Route } from "next"; import Link from "next/link"; import { AuthGuard } from "@/components/auth/auth-guard"; diff --git a/web/src/app/knowledge/page.tsx b/web/src/app/knowledge/page.tsx index f5785060..99709dd0 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -2,10 +2,18 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; -import { ChevronRight, FileText, Folder, FolderOpen, Plus } from "lucide-react"; +import { + ChevronRight, + Ellipsis, + FileText, + Folder, + FolderOpen, + Plus, +} from "lucide-react"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { DropdownButtons } from "@/components/dropdown"; import { TitleShell } from "@/components/layouts/title-shell"; import { Modal } from "@/components/modal"; import { Button } from "@/components/ui/button"; @@ -93,6 +101,20 @@ function KnowledgePage() { const [pendingAction, setPendingAction] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const [deleteTarget, setDeleteTarget] = useState<{ + kind: Row["kind"]; + id: string; + name: string; + } | null>(null); + const [editTarget, setEditTarget] = useState<{ + kind: Row["kind"]; + id: string; + } | null>(null); + const [editFolderTitle, setEditFolderTitle] = useState(""); + const [editItemName, setEditItemName] = useState(""); + const [editItemDescription, setEditItemDescription] = useState(""); + const [editItemBody, setEditItemBody] = useState(""); + const [copied, setCopied] = useState(false); const rootFoldersQuery = useQuery({ queryKey: ["knowledge", "folders", "root"], @@ -414,6 +436,96 @@ function KnowledgePage() { const openedItem = openedItemQuery.data ?? null; + const handleCopyLink = async () => { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const openDeleteConfirm = (row: Row) => { + setDeleteTarget({ kind: row.kind, id: row.id, name: row.name }); + }; + + const openEditFolder = (row: Row & { kind: "folder" }) => { + setEditFolderTitle(row.name); + setEditTarget({ kind: "folder", id: row.id }); + }; + + const openEditItem = (row: Row & { kind: "item" }) => { + setEditItemName(row.raw.name); + setEditItemDescription(row.raw.description ?? ""); + setEditItemBody(row.raw.body ?? ""); + setEditTarget({ kind: "item", id: row.id }); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setPendingAction("delete"); + clearMessages(); + try { + if (deleteTarget.kind === "folder") { + await trpcClient.knowledge.deleteFolder.mutate({ + folderId: deleteTarget.id, + }); + } else { + await trpcClient.knowledge.deleteItem.mutate({ + itemId: deleteTarget.id, + }); + } + await refreshKnowledge(); + setSuccessMessage(`Deleted "${deleteTarget.name}".`); + setDeleteTarget(null); + } catch (error) { + setErrorMessage(toErrorMessage(error)); + } finally { + setPendingAction(null); + } + }; + + const handleEditFolder = async () => { + if (!editTarget || editTarget.kind !== "folder") return; + const title = editFolderTitle.trim(); + if (!title) return; + setPendingAction("edit-folder"); + clearMessages(); + try { + await trpcClient.knowledge.updateFolderName.mutate({ + folderId: editTarget.id, + title, + }); + await refreshKnowledge(); + setSuccessMessage(`Renamed folder to "${title}".`); + setEditTarget(null); + } catch (error) { + setErrorMessage(toErrorMessage(error)); + } finally { + setPendingAction(null); + } + }; + + const handleEditItem = async () => { + if (!editTarget || editTarget.kind !== "item") return; + const name = editItemName.trim(); + if (!name) return; + setPendingAction("edit-item"); + clearMessages(); + try { + await trpcClient.knowledge.updateItem.mutate({ + itemId: editTarget.id, + name, + description: editItemDescription.trim() || null, + body: editItemBody.trim() || null, + }); + await refreshKnowledge(); + setSuccessMessage(`Updated "${name}".`); + setEditTarget(null); + } catch (error) { + setErrorMessage(toErrorMessage(error)); + } finally { + setPendingAction(null); + } + }; + return (
@@ -471,6 +583,15 @@ function KnowledgePage() { > Back + + + )} + {row.name} + + + {formatDate(row.updatedAt)} + + +
+ + + + } + /> +
+
); }) )} @@ -781,6 +983,125 @@ function KnowledgePage() { + { + if (!open) setDeleteTarget(null); + }} + title={`Delete "${deleteTarget?.name ?? ""}"`} + description={`Are you sure you want to delete this ${deleteTarget?.kind ?? "item"}? This cannot be undone.`} + className="max-w-md" + footer={ + <> + + + + } + /> + + { + if (!open) setEditTarget(null); + }} + title="Rename Folder" + className="max-w-md" + footer={ + <> + + + + } + > + setEditFolderTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleEditFolder(); + }} + disabled={isBusy} + autoFocus + /> + + + { + if (!open) setEditTarget(null); + }} + title="Edit Item" + className="max-w-2xl" + footer={ + <> + + + + } + > +
+ setEditItemName(e.target.value)} + disabled={isBusy} + autoFocus + /> + setEditItemDescription(e.target.value)} + disabled={isBusy} + /> +