From dba2453453ca762f8a32e923dad2a29c690a9fb6 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 3 Feb 2024 07:57:29 -0500 Subject: [PATCH 1/5] added support for nested collection (backend) --- .../collectionId/deleteCollectionById.ts | 36 ++++++++++++++++++- .../collectionId/getCollectionById.ts | 35 ++++++++++++++++++ .../collectionId/updateCollectionById.ts | 26 +++++++++++++- .../controllers/collections/postCollection.ts | 27 ++++++++++++++ pages/api/v1/collections/[id].ts | 21 +++++------ .../migration.sql | 5 +++ prisma/schema.prisma | 25 +++++++------ scripts/worker.ts | 2 +- store/collections.ts | 6 +++- 9 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 lib/api/controllers/collections/collectionId/getCollectionById.ts create mode 100644 prisma/migrations/20240125124457_added_subcollection_relations/migration.sql diff --git a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts index 03870aa..98b1e17 100644 --- a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts @@ -37,6 +37,8 @@ export default async function deleteCollection( } const deletedCollection = await prisma.$transaction(async () => { + await deleteSubCollections(collectionId); + await prisma.usersAndCollections.deleteMany({ where: { collection: { @@ -53,7 +55,7 @@ export default async function deleteCollection( }, }); - removeFolder({ filePath: `archives/${collectionId}` }); + await removeFolder({ filePath: `archives/${collectionId}` }); return await prisma.collection.delete({ where: { @@ -64,3 +66,35 @@ export default async function deleteCollection( return { response: deletedCollection, status: 200 }; } + +async function deleteSubCollections(collectionId: number) { + const subCollections = await prisma.collection.findMany({ + where: { parentId: collectionId }, + }); + + for (const subCollection of subCollections) { + await deleteSubCollections(subCollection.id); + + await prisma.usersAndCollections.deleteMany({ + where: { + collection: { + id: subCollection.id, + }, + }, + }); + + await prisma.link.deleteMany({ + where: { + collection: { + id: subCollection.id, + }, + }, + }); + + await prisma.collection.delete({ + where: { id: subCollection.id }, + }); + + await removeFolder({ filePath: `archives/${subCollection.id}` }); + } +} diff --git a/lib/api/controllers/collections/collectionId/getCollectionById.ts b/lib/api/controllers/collections/collectionId/getCollectionById.ts new file mode 100644 index 0000000..e508399 --- /dev/null +++ b/lib/api/controllers/collections/collectionId/getCollectionById.ts @@ -0,0 +1,35 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getCollectionById( + userId: number, + collectionId: number +) { + const collections = await prisma.collection.findFirst({ + where: { + id: collectionId, + OR: [ + { ownerId: userId }, + { members: { some: { user: { id: userId } } } }, + ], + }, + include: { + _count: { + select: { links: true }, + }, + subCollections: true, + members: { + include: { + user: { + select: { + username: true, + name: true, + image: true, + }, + }, + }, + }, + }, + }); + + return { response: collections, status: 200 }; +} diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index f257021..e300662 100644 --- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/api/db"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import getPermission from "@/lib/api/getPermission"; -import { Collection, UsersAndCollections } from "@prisma/client"; export default async function updateCollection( userId: number, @@ -19,6 +18,26 @@ export default async function updateCollection( if (!(collectionIsAccessible?.ownerId === userId)) return { response: "Collection is not accessible.", status: 401 }; + if (data.parentId) { + const findParentCollection = await prisma.collection.findUnique({ + where: { + id: data.parentId, + }, + select: { + ownerId: true, + }, + }); + + if ( + findParentCollection?.ownerId !== userId || + typeof data.parentId !== "number" + ) + return { + response: "You are not authorized to create a sub-collection here.", + status: 403, + }; + } + const updatedCollection = await prisma.$transaction(async () => { await prisma.usersAndCollections.deleteMany({ where: { @@ -38,6 +57,11 @@ export default async function updateCollection( description: data.description, color: data.color, isPublic: data.isPublic, + parent: { + connect: { + id: data.parentId || undefined, + }, + }, members: { create: data.members.map((e) => ({ user: { connect: { id: e.user.id || e.userId } }, diff --git a/lib/api/controllers/collections/postCollection.ts b/lib/api/controllers/collections/postCollection.ts index 86ca379..a69d016 100644 --- a/lib/api/controllers/collections/postCollection.ts +++ b/lib/api/controllers/collections/postCollection.ts @@ -12,6 +12,26 @@ export default async function postCollection( status: 400, }; + if (collection.parentId) { + const findParentCollection = await prisma.collection.findUnique({ + where: { + id: collection.parentId, + }, + select: { + ownerId: true, + }, + }); + + if ( + findParentCollection?.ownerId !== userId || + typeof collection.parentId !== "number" + ) + return { + response: "You are not authorized to create a sub-collection here.", + status: 403, + }; + } + const findCollection = await prisma.user.findUnique({ where: { id: userId, @@ -40,6 +60,13 @@ export default async function postCollection( name: collection.name.trim(), description: collection.description, color: collection.color, + parent: collection.parentId + ? { + connect: { + id: collection.parentId, + }, + } + : undefined, }, include: { _count: { diff --git a/pages/api/v1/collections/[id].ts b/pages/api/v1/collections/[id].ts index 4f8020c..637e10f 100644 --- a/pages/api/v1/collections/[id].ts +++ b/pages/api/v1/collections/[id].ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import getCollectionById from "@/lib/api/controllers/collections/collectionId/getCollectionById"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; import verifyUser from "@/lib/api/verifyUser"; @@ -10,18 +11,18 @@ export default async function collections( const user = await verifyUser({ req, res }); if (!user) return; - if (req.method === "PUT") { - const updated = await updateCollectionById( - user.id, - Number(req.query.id) as number, - req.body - ); + const collectionId = Number(req.query.id); + + if (req.method === "GET") { + const collections = await getCollectionById(user.id, collectionId); + return res + .status(collections.status) + .json({ response: collections.response }); + } else if (req.method === "PUT") { + const updated = await updateCollectionById(user.id, collectionId, req.body); return res.status(updated.status).json({ response: updated.response }); } else if (req.method === "DELETE") { - const deleted = await deleteCollectionById( - user.id, - Number(req.query.id) as number - ); + const deleted = await deleteCollectionById(user.id, collectionId); return res.status(deleted.status).json({ response: deleted.response }); } } diff --git a/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql b/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql new file mode 100644 index 0000000..9479956 --- /dev/null +++ b/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Collection" ADD COLUMN "parentId" INTEGER; + +-- AddForeignKey +ALTER TABLE "Collection" ADD CONSTRAINT "Collection_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Collection"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aebc98f..23ff147 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,17 +69,20 @@ model VerificationToken { } model Collection { - id Int @id @default(autoincrement()) - name String - description String @default("") - color String @default("#0ea5e9") - isPublic Boolean @default(false) - owner User @relation(fields: [ownerId], references: [id]) - ownerId Int - members UsersAndCollections[] - links Link[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + id Int @id @default(autoincrement()) + name String + description String @default("") + color String @default("#0ea5e9") + parentId Int? + parent Collection? @relation("SubCollections", fields: [parentId], references: [id]) + subCollections Collection[] @relation("SubCollections") + isPublic Boolean @default(false) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + members UsersAndCollections[] + links Link[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt @@unique([name, ownerId]) } diff --git a/scripts/worker.ts b/scripts/worker.ts index de025ea..5cddcf1 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import "dotenv/config"; import { Collection, Link, User } from "@prisma/client"; import { prisma } from "../lib/api/db"; import archiveHandler from "../lib/api/archiveHandler"; diff --git a/store/collections.ts b/store/collections.ts index 5c78b52..466b652 100644 --- a/store/collections.ts +++ b/store/collections.ts @@ -78,7 +78,11 @@ const useCollectionStore = create()((set) => ({ if (response.ok) { set((state) => ({ - collections: state.collections.filter((e) => e.id !== collectionId), + collections: state.collections.filter( + (collection) => + collection.id !== collectionId && + collection.parentId !== collectionId + ), })); useTagStore.getState().setTags(); } From 00bfdfb9264b100ad1474b240b8795c0bf1d6449 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 5 Feb 2024 02:42:54 -0500 Subject: [PATCH 2/5] add support for subcollections to the navbar --- components/CollectionSelection.tsx | 139 ++++++++++++++++++ components/ModalContent/NewTokenModal.tsx | 12 +- components/Sidebar.tsx | 45 +----- .../collectionId/updateCollectionById.ts | 1 + .../controllers/collections/getCollections.ts | 1 + pages/settings/access-tokens.tsx | 4 +- types/global.ts | 1 + 7 files changed, 153 insertions(+), 50 deletions(-) create mode 100644 components/CollectionSelection.tsx diff --git a/components/CollectionSelection.tsx b/components/CollectionSelection.tsx new file mode 100644 index 0000000..47a449f --- /dev/null +++ b/components/CollectionSelection.tsx @@ -0,0 +1,139 @@ +import useCollectionStore from "@/store/collections"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React, { useEffect, useState } from "react"; + +type Props = { + links: boolean; +}; + +const CollectionSelection = ({ links }: Props) => { + const { collections } = useCollectionStore(); + const [active, setActive] = useState(""); + const router = useRouter(); + + useEffect(() => { + setActive(router.asPath); + }, [router, collections]); + + return ( +
+ {collections[0] ? ( + collections + .sort((a, b) => a.name.localeCompare(b.name)) + .filter((e) => e.parentId === null) + .map((e, i) => ( + + )) + ) : ( +
+

+ You Have No Collections... +

+
+ )} +
+ ); +}; + +export default CollectionSelection; + +const CollectionItem = ({ + collection, + active, + collections, +}: { + collection: CollectionIncludingMembersAndLinkCount; + active: string; + collections: CollectionIncludingMembersAndLinkCount[]; +}) => { + const hasChildren = + collection.subCollections && collection.subCollections.length > 0; + + const router = useRouter(); + + return hasChildren ? ( +
+ + +
+ +

{collection.name}

+ + {collection.isPublic ? ( + + ) : undefined} +
+ {collection._count?.links} +
+
+ +
+ + {/* Nested Collections, make it recursively */} + + {hasChildren && ( +
+ {collections + .filter((e) => e.parentId === collection.id) + .map((subCollection) => ( + + ))} +
+ )} +
+ ) : ( + +
+ +

{collection.name}

+ + {collection.isPublic ? ( + + ) : undefined} +
+ {collection._count?.links} +
+
+ + ); +}; diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx index 50565ed..2092eaa 100644 --- a/components/ModalContent/NewTokenModal.tsx +++ b/components/ModalContent/NewTokenModal.tsx @@ -74,7 +74,7 @@ export default function NewTokenModal({ onClose }: Props) {
-
+

Name

@@ -86,15 +86,15 @@ export default function NewTokenModal({ onClose }: Props) { />
-
+

Expires in

-
+
{token.expires === TokenExpiry.sevenDays && "7 Days"} {token.expires === TokenExpiry.oneMonth && "30 Days"} @@ -102,7 +102,7 @@ export default function NewTokenModal({ onClose }: Props) { {token.expires === TokenExpiry.threeMonths && "90 Days"} {token.expires === TokenExpiry.never && "No Expiration"}
-
    +
-
+