diff --git a/apps/web/actions/folders/getAllFolders.ts b/apps/web/actions/folders/getAllFolders.ts new file mode 100644 index 000000000..847405768 --- /dev/null +++ b/apps/web/actions/folders/getAllFolders.ts @@ -0,0 +1,35 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import { CurrentUser } from "@cap/web-domain"; +import { Effect } from "effect"; +import { getAllFolders } from "../../lib/folder"; +import { runPromise } from "../../lib/server"; + +export async function getAllFoldersAction( + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + try { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) { + return { + success: false as const, + error: "Unauthorized or no active organization", + }; + } + + const folders = await runPromise( + getAllFolders(root).pipe(Effect.provideService(CurrentUser, user)) + ); + return { success: true as const, folders }; + } catch (error) { + console.error("Error fetching folders:", error); + return { + success: false as const, + error: "Failed to fetch folders", + }; + } +} diff --git a/apps/web/actions/folders/move-videos-to-folder.ts b/apps/web/actions/folders/move-videos-to-folder.ts new file mode 100644 index 000000000..7d6ef3aec --- /dev/null +++ b/apps/web/actions/folders/move-videos-to-folder.ts @@ -0,0 +1,98 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import { CurrentUser, Video, Folder, Policy } from "@cap/web-domain"; +import { SpacesPolicy } from "@cap/web-backend"; +import { Effect } from "effect"; +import { moveVideosToFolder } from "../../lib/folder"; +import { runPromise } from "../../lib/server"; +import { revalidatePath } from "next/cache"; + +interface MoveVideosToFolderParams { + videoIds: string[]; + targetFolderId: string | null; + spaceId?: string | null; +} + +export async function moveVideosToFolderAction({ + videoIds, + targetFolderId, + spaceId, +}: MoveVideosToFolderParams) { + try { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) { + return { + success: false as const, + error: "Unauthorized or no active organization", + }; + } + + const typedVideoIds = videoIds.map((id) => Video.VideoId.make(id)); + const typedTargetFolderId = targetFolderId + ? Folder.FolderId.make(targetFolderId) + : null; + + const root = spaceId + ? { variant: "space" as const, spaceId } + : { variant: "org" as const, organizationId: user.activeOrganizationId }; + + const moveVideosEffect = spaceId + ? Effect.gen(function* () { + const spacesPolicy = yield* SpacesPolicy; + + return yield* moveVideosToFolder( + typedVideoIds, + typedTargetFolderId, + root + ).pipe(Policy.withPolicy(spacesPolicy.isMember(spaceId))); + }).pipe(Effect.provideService(CurrentUser, user)) + : moveVideosToFolder(typedVideoIds, typedTargetFolderId, root).pipe( + Effect.provideService(CurrentUser, user) + ); + + const result = await runPromise(moveVideosEffect); + + revalidatePath("/dashboard/caps"); + + if (spaceId) { + revalidatePath(`/dashboard/spaces/${spaceId}`); + result.originalFolderIds.forEach((folderId) => { + if (folderId) { + revalidatePath(`/dashboard/spaces/${spaceId}/folder/${folderId}`); + } + }); + if (result.targetFolderId) { + revalidatePath( + `/dashboard/spaces/${spaceId}/folder/${result.targetFolderId}` + ); + } + } else { + result.originalFolderIds.forEach((folderId) => { + if (folderId) { + revalidatePath(`/dashboard/folder/${folderId}`); + } + }); + if (result.targetFolderId) { + revalidatePath(`/dashboard/folder/${result.targetFolderId}`); + } + } + + return { + success: true as const, + message: `Successfully moved ${result.movedCount} video${ + result.movedCount !== 1 ? "s" : "" + } to ${result.targetFolderId ? "folder" : "root"}`, + movedCount: result.movedCount, + originalFolderIds: result.originalFolderIds, + targetFolderId: result.targetFolderId, + videoCountDeltas: result.videoCountDeltas, + }; + } catch (error) { + console.error("Error moving videos to folder:", error); + return { + success: false as const, + error: error instanceof Error ? error.message : "Failed to move videos", + }; + } +} diff --git a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/app/(org)/dashboard/_components/folder-selection-dialog.tsx b/apps/web/app/(org)/dashboard/_components/folder-selection-dialog.tsx new file mode 100644 index 000000000..5e243c18c --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/folder-selection-dialog.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@cap/ui"; +import { + faFolderOpen, + faChevronRight, + faChevronDown, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffectMutation, useEffectQuery } from "@/lib/EffectRuntime"; +import { useState } from "react"; +import { getAllFoldersAction } from "../../../../actions/folders/getAllFolders"; +import { moveVideosToFolderAction } from "../../../../actions/folders/move-videos-to-folder"; +import { useDashboardContext } from "../Contexts"; +import { toast } from "sonner"; +import { Effect } from "effect"; +import { useQueryClient } from "@tanstack/react-query"; + +type FolderWithChildren = { + id: string; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + parentId: string | null; + organizationId: string; + videoCount: number; + children: FolderWithChildren[]; +}; + +interface FolderSelectionDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (folderId: string | null) => void; + selectedCount: number; + videoIds: string[]; +} + +export function FolderSelectionDialog({ + open, + onClose, + onConfirm, + selectedCount, + videoIds, +}: FolderSelectionDialogProps) { + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>( + new Set() + ); + const { activeOrganization, activeSpace } = useDashboardContext(); + const queryClient = useQueryClient(); + + const { data: foldersData, isLoading } = useEffectQuery({ + queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id], + queryFn: () => + Effect.tryPromise(() => { + const root = activeSpace?.id + ? { variant: "space" as const, spaceId: activeSpace.id } + : { + variant: "org" as const, + organizationId: activeOrganization!.organization.id, + }; + + return getAllFoldersAction(root).then((result) => { + if (!result.success) { + throw new Error(result.error || "Failed to fetch folders"); + } + return result.folders; + }); + }), + enabled: open && !!activeOrganization?.organization.id, + }); + + const folders = foldersData || []; + + const moveVideosMutation = useEffectMutation({ + mutationFn: (params: { + videoIds: string[]; + targetFolderId: string | null; + spaceId?: string | null; + }) => + Effect.tryPromise(() => + moveVideosToFolderAction(params).then((result) => { + if (!result.success) { + throw new Error(result.error || "Failed to move videos"); + } + return result; + }) + ), + onSuccess: (result, variables) => { + const confirmedFolderId = result.targetFolderId; + const confirmedVideoIds = variables.videoIds; + + toast.success(result.message); + + const foldersQueryKey = [ + "folders", + activeOrganization?.organization.id, + activeSpace?.id, + ]; + + const applyFolderDeltas = ( + nodes: FolderWithChildren[], + deltas: Record + ): FolderWithChildren[] => + nodes.map((node) => ({ + ...node, + videoCount: Math.max(0, node.videoCount + (deltas[node.id] ?? 0)), + children: node.children + ? applyFolderDeltas(node.children, deltas) + : [], + })); + + queryClient.setQueryData( + foldersQueryKey, + (oldFolders: FolderWithChildren[] | undefined) => { + if (!oldFolders) return oldFolders; + return applyFolderDeltas(oldFolders, result.videoCountDeltas); + } + ); + + queryClient.setQueriesData( + { queryKey: ["videos"] }, + (oldVideos: any[] | undefined) => { + if (!oldVideos) return oldVideos; + + return oldVideos.map((video) => + confirmedVideoIds.includes(video.id) + ? { ...video, folderId: confirmedFolderId } + : video + ); + } + ); + + onConfirm(confirmedFolderId); + setSelectedFolderId(null); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to move videos" + ); + console.error("Error moving videos:", error); + }, + }); + + const toggleFolderExpansion = (folderId: string) => { + setExpandedFolders((prev) => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }; + + const renderFolder = (folder: FolderWithChildren, depth = 0) => { + const hasChildren = folder.children && folder.children.length > 0; + const isExpanded = expandedFolders.has(folder.id); + const isSelected = selectedFolderId === folder.id; + + return ( +
+
setSelectedFolderId(folder.id)} + className={` + group flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer + transition-colors border-l-4 + ${ + isSelected + ? "bg-blue-3 border-blue-9" + : "border-transparent hover:bg-gray-3" + } + `} + style={{ marginLeft: `${depth * 16}px` }} + > + {hasChildren ? ( + + ) : ( +
+ )} + +
+
+ +
+ +
+

{folder.name}

+

+ {folder.videoCount} video{folder.videoCount !== 1 ? "s" : ""} +

+
+
+ + {isSelected && ( +
+
+
+ )} +
+ + {hasChildren && isExpanded && ( +
+ {folder.children.map((child) => renderFolder(child, depth + 1))} +
+ )} +
+ ); + }; + + const handleConfirm = () => { + moveVideosMutation.mutate({ + videoIds, + targetFolderId: selectedFolderId, + spaceId: activeSpace?.id, + }); + }; + + const handleCancel = () => { + setSelectedFolderId(null); + onClose(); + }; + + return ( + !open && handleCancel()}> + + }> + + Move {selectedCount} cap{selectedCount !== 1 ? "s" : ""} to folder + + +
+

+ Select a destination folder for the selected caps. +

+ +
+
setSelectedFolderId(null)} + className={` + flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer + transition-colors border-l-4 + ${ + selectedFolderId === null + ? "bg-blue-3 border-blue-9" + : "border-transparent hover:bg-gray-3" + } + `} + > +
+ +
+
+

+ {activeOrganization?.organization.name || "My Caps"} +

+

Move to root folder

+
+ {selectedFolderId === null && ( +
+
+
+ )} +
+ + {isLoading && ( +
+

Loading folders...

+
+ )} + + {!isLoading && + folders.map((folder: FolderWithChildren) => renderFolder(folder))} +
+ + {!isLoading && folders.length === 0 && ( +
+ +

No folders available

+

+ Create a folder first to organize your caps +

+
+ )} +
+ + + + + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx index 8f82e8c6d..eaf68153b 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx @@ -1,98 +1,130 @@ "use client"; import { Button } from "@cap/ui"; -import { faFilm, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + faFilm, + faTrash, + faFolderOpen, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import NumberFlow from "@number-flow/react"; import { AnimatePresence, motion } from "framer-motion"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; +import { FolderSelectionDialog } from "@/app/(org)/dashboard/_components/folder-selection-dialog"; interface SelectedCapsBarProps { - selectedCaps: string[]; - setSelectedCaps: (caps: Video.VideoId[]) => void; - deleteSelectedCaps: () => void; - isDeleting: boolean; + selectedCaps: string[]; + setSelectedCaps: (caps: Video.VideoId[]) => void; + deleteSelectedCaps: () => void; + isDeleting: boolean; + moveSelectedCaps?: (folderId: string | null) => void; } import type { Video } from "@cap/web-domain"; import { useState } from "react"; export const SelectedCapsBar = ({ - selectedCaps, - setSelectedCaps, - deleteSelectedCaps, - isDeleting, + selectedCaps, + setSelectedCaps, + deleteSelectedCaps, + isDeleting, + moveSelectedCaps, }: SelectedCapsBarProps) => { - const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [moveDialogOpen, setMoveDialogOpen] = useState(false); - const handleConfirmDelete = () => { - deleteSelectedCaps(); - setConfirmOpen(false); - }; + const handleConfirmDelete = () => { + deleteSelectedCaps(); + setConfirmOpen(false); + }; - return ( - - {selectedCaps.length > 0 && ( - -
- - cap{selectedCaps.length !== 1 ? "s" : ""} selected -
-
- - - } - title="Delete selected Caps" - description={`Are you sure you want to delete ${ - selectedCaps.length - } cap${ - selectedCaps.length === 1 ? "" : "s" - }? This action cannot be undone.`} - confirmLabel={isDeleting ? "Deleting..." : "Delete"} - cancelLabel="Cancel" - confirmVariant="dark" - loading={isDeleting} - onConfirm={handleConfirmDelete} - onCancel={() => setConfirmOpen(false)} - /> -
-
- )} -
- ); + const handleMoveToFolder = (folderId: string | null) => { + if (moveSelectedCaps) { + moveSelectedCaps(folderId); + } + setMoveDialogOpen(false); + }; + + return ( + + {selectedCaps.length > 0 && ( + +
+ + cap{selectedCaps.length !== 1 ? "s" : ""} selected +
+
+ + + + } + title="Delete selected Caps" + description={`Are you sure you want to delete ${ + selectedCaps.length + } cap${ + selectedCaps.length === 1 ? "" : "s" + }? This action cannot be undone.`} + confirmLabel={isDeleting ? "Deleting..." : "Delete"} + cancelLabel="Cancel" + confirmVariant="dark" + loading={isDeleting} + onConfirm={handleConfirmDelete} + onCancel={() => setConfirmOpen(false)} + /> + + setMoveDialogOpen(false)} + onConfirm={handleMoveToFolder} + selectedCount={selectedCaps.length} + videoIds={selectedCaps} + /> +
+
+ )} +
+ ); }; diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index ef6d7c260..0c66c888f 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -3,181 +3,176 @@ import "server-only"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - comments, - folders, - organizations, - sharedVideos, - spaces, - spaceVideos, - users, - videos, - videoUploads, + comments, + folders, + organizations, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, } from "@cap/database/schema"; import { Database } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { CurrentUser, Folder } from "@cap/web-domain"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { sql } from "drizzle-orm/sql"; import { Effect } from "effect"; import { revalidatePath } from "next/cache"; export const getFolderById = Effect.fn(function* (folderId: string) { - if (!folderId) throw new Error("Folder ID is required"); - const db = yield* Database; + if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; - const [folder] = yield* db.execute((db) => - db - .select() - .from(folders) - .where(eq(folders.id, Folder.FolderId.make(folderId))), - ); + const [folder] = yield* db.execute((db) => + db + .select() + .from(folders) + .where(eq(folders.id, Folder.FolderId.make(folderId))) + ); - if (!folder) throw new Error("Folder not found"); + if (!folder) throw new Error("Folder not found"); - return folder; + return folder; }); export const getFolderBreadcrumb = Effect.fn(function* ( - folderId: Folder.FolderId, + folderId: Folder.FolderId ) { - const breadcrumb: Array<{ - id: Folder.FolderId; - name: string; - color: "normal" | "blue" | "red" | "yellow"; - }> = []; - let currentFolderId = folderId; - - while (currentFolderId) { - const folder = yield* getFolderById(currentFolderId); - if (!folder) break; - - breadcrumb.unshift({ - id: folder.id, - name: folder.name, - color: folder.color, - }); - - if (!folder.parentId) break; - currentFolderId = folder.parentId; - } - - return breadcrumb; + const breadcrumb: Array<{ + id: Folder.FolderId; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + }> = []; + let currentFolderId = folderId; + + while (currentFolderId) { + const folder = yield* getFolderById(currentFolderId); + if (!folder) break; + + breadcrumb.unshift({ + id: folder.id, + name: folder.name, + color: folder.color, + }); + + if (!folder.parentId) break; + currentFolderId = folder.parentId; + } + + return breadcrumb; }); -// Helper function to fetch shared spaces data for videos const getSharedSpacesForVideos = Effect.fn(function* ( - videoIds: Video.VideoId[], + videoIds: Video.VideoId[] ) { - if (videoIds.length === 0) return {}; - const db = yield* Database; - - // Fetch space-level sharing - const spaceSharing = yield* db.execute((db) => - db - .select({ - videoId: spaceVideos.videoId, - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where( - sql`${spaceVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - // Fetch organization-level sharing - const orgSharing = yield* db.execute((db) => - db - .select({ - videoId: sharedVideos.videoId, - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin( - organizations, - eq(sharedVideos.organizationId, organizations.id), - ) - .where( - sql`${sharedVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - // Combine and group by videoId - const sharedSpacesMap: Record< - string, - Array<{ - id: string; - name: string; - organizationId: string; - iconUrl: string; - isOrg: boolean; - }> - > = {}; - - // Add space-level sharing - spaceSharing.forEach((space) => { - const spaces = sharedSpacesMap[space.videoId] ?? []; - sharedSpacesMap[space.videoId] = spaces; - spaces.push({ - id: space.id, - name: space.name, - organizationId: space.organizationId, - iconUrl: space.iconUrl || "", - isOrg: false, - }); - }); - - // Add organization-level sharing - orgSharing.forEach((org) => { - const spaces = sharedSpacesMap[org.videoId] ?? []; - sharedSpacesMap[org.videoId] = spaces; - - spaces.push({ - id: org.id, - name: org.name, - organizationId: org.organizationId, - iconUrl: org.iconUrl || "", - isOrg: true, - }); - }); - - return sharedSpacesMap; + if (videoIds.length === 0) return {}; + const db = yield* Database; + + const spaceSharing = yield* db.execute((db) => + db + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where( + sql`${spaceVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + ); + + const orgSharing = yield* db.execute((db) => + db + .select({ + videoId: sharedVideos.videoId, + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id) + ) + .where( + sql`${sharedVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + ); + + const sharedSpacesMap: Record< + string, + Array<{ + id: string; + name: string; + organizationId: string; + iconUrl: string; + isOrg: boolean; + }> + > = {}; + + spaceSharing.forEach((space) => { + const spaces = sharedSpacesMap[space.videoId] ?? []; + sharedSpacesMap[space.videoId] = spaces; + spaces.push({ + id: space.id, + name: space.name, + organizationId: space.organizationId, + iconUrl: space.iconUrl || "", + isOrg: false, + }); + }); + + // Add organization-level sharing + orgSharing.forEach((org) => { + const spaces = sharedSpacesMap[org.videoId] ?? []; + sharedSpacesMap[org.videoId] = spaces; + + spaces.push({ + id: org.id, + name: org.name, + organizationId: org.organizationId, + iconUrl: org.iconUrl || "", + isOrg: true, + }); + }); + + return sharedSpacesMap; }); export const getVideosByFolderId = Effect.fn(function* ( - folderId: Folder.FolderId, + folderId: Folder.FolderId ) { - if (!folderId) throw new Error("Folder ID is required"); - const db = yield* Database; - - const videoData = yield* db.execute((db) => - db - .select({ - id: videos.id, - ownerId: videos.ownerId, - name: videos.name, - createdAt: videos.createdAt, - public: videos.public, - metadata: videos.metadata, - duration: videos.duration, - totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, - totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql< - { id: string; name: string; iconUrl: string }[] - >` + if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; + + const videoData = yield* db.execute((db) => + db + .select({ + id: videos.id, + ownerId: videos.ownerId, + name: videos.name, + createdAt: videos.createdAt, + public: videos.public, + metadata: videos.metadata, + duration: videos.duration, + totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, + totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, + sharedOrganizations: sql< + { id: string; name: string; iconUrl: string }[] + >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( @@ -190,117 +185,346 @@ export const getVideosByFolderId = Effect.fn(function* ( ) `, - ownerName: users.name, - effectiveDate: sql` + ownerName: users.name, + effectiveDate: sql` COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} ) `, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), - }) - .from(videos) - .leftJoin(comments, eq(videos.id, comments.videoId)) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .leftJoin( - organizations, - eq(sharedVideos.organizationId, organizations.id), - ) - .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .where(eq(videos.folderId, folderId)) - .groupBy( - videos.id, - videos.ownerId, - videos.name, - videos.createdAt, - videos.public, - videos.metadata, - users.name, - ) - .orderBy( - desc(sql`COALESCE( + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean + ), + }) + .from(videos) + .leftJoin(comments, eq(videos.id, comments.videoId)) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .leftJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id) + ) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.folderId, folderId)) + .groupBy( + videos.id, + videos.ownerId, + videos.name, + videos.createdAt, + videos.public, + videos.metadata, + users.name + ) + .orderBy( + desc(sql`COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} - )`), - ), - ); - - // Fetch shared spaces data for all videos - const videoIds = videoData.map((video) => video.id); - const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); - - // Process the video data to match the expected format - const processedVideoData = videoData.map((video) => { - return { - id: video.id as Video.VideoId, // Cast to Video.VideoId branded type - ownerId: video.ownerId, - name: video.name, - createdAt: video.createdAt, - public: video.public, - totalComments: video.totalComments, - totalReactions: video.totalReactions, - sharedOrganizations: Array.isArray(video.sharedOrganizations) - ? video.sharedOrganizations.filter( - (organization) => organization.id !== null, - ) - : [], - sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) - ? sharedSpacesMap[video.id] - : [], - ownerName: video.ownerName ?? "", - metadata: video.metadata as - | { - customCreatedAt?: string; - [key: string]: unknown; - } - | undefined, - hasPassword: video.hasPassword, - hasActiveUpload: video.hasActiveUpload, - foldersData: [], // Empty array since videos in a folder don't need folder data - }; - }); - - return processedVideoData; + )`) + ) + ); + + const videoIds = videoData.map((video) => video.id); + const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); + + const processedVideoData = videoData.map((video) => { + return { + id: video.id as Video.VideoId, + ownerId: video.ownerId, + name: video.name, + createdAt: video.createdAt, + public: video.public, + totalComments: video.totalComments, + totalReactions: video.totalReactions, + sharedOrganizations: Array.isArray(video.sharedOrganizations) + ? video.sharedOrganizations.filter( + (organization) => organization.id !== null + ) + : [], + sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) + ? sharedSpacesMap[video.id] + : [], + ownerName: video.ownerName ?? "", + metadata: video.metadata as + | { + customCreatedAt?: string; + [key: string]: unknown; + } + | undefined, + hasPassword: video.hasPassword, + hasActiveUpload: video.hasActiveUpload, + foldersData: [], // Empty array since videos in a folder don't need folder data + }; + }); + + return processedVideoData; }); export const getChildFolders = Effect.fn(function* ( - folderId: Folder.FolderId, - root: - | { variant: "user" } - | { variant: "space"; spaceId: string } - | { variant: "org"; organizationId: string }, + folderId: Folder.FolderId, + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + const db = yield* Database; + + const user = yield* CurrentUser; + if (!user.activeOrganizationId) throw new Error("No active organization"); + + const childFolders = yield* db.execute((db) => + db + .select({ + id: folders.id, + name: folders.name, + color: folders.color, + parentId: folders.parentId, + organizationId: folders.organizationId, + videoCount: + root.variant === "space" + ? sql`( + SELECT COUNT(*) + FROM space_videos + WHERE space_videos.folderId = folders.id + AND space_videos.spaceId = ${root.spaceId} + )` + : sql`( + SELECT COUNT(*) + FROM videos WHERE videos.folderId = folders.id + )`, + }) + .from(folders) + .where( + and( + eq(folders.parentId, folderId), + eq(folders.organizationId, user.activeOrganizationId), + root.variant === "space" + ? eq(folders.spaceId, root.spaceId) + : isNull(folders.spaceId) + ) + ) + ); + + return childFolders; +}); + +export const getAllFolders = Effect.fn(function* ( + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } ) { - const db = yield* Database; - - const user = yield* CurrentUser; - if (!user.activeOrganizationId) throw new Error("No active organization"); - - const childFolders = yield* db.execute((db) => - db - .select({ - id: folders.id, - name: folders.name, - color: folders.color, - parentId: folders.parentId, - organizationId: folders.organizationId, - videoCount: sql`( - SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id - )`, - }) - .from(folders) - .where( - and( - eq(folders.parentId, folderId), - root.variant === "space" - ? eq(folders.spaceId, root.spaceId) - : undefined, - ), - ), - ); - - return childFolders; + const db = yield* Database; + const user = yield* CurrentUser; + + if (!user.activeOrganizationId) throw new Error("No active organization"); + + const allFolders = yield* db.execute((db) => + db + .select({ + id: folders.id, + name: folders.name, + color: folders.color, + parentId: folders.parentId, + organizationId: folders.organizationId, + videoCount: + root.variant === "space" + ? sql`( + SELECT COUNT(*) + FROM space_videos + WHERE space_videos.folderId = folders.id + AND space_videos.spaceId = ${root.spaceId} + )` + : sql`( + SELECT COUNT(*) + FROM videos WHERE videos.folderId = folders.id + )`, + }) + .from(folders) + .where( + and( + eq(folders.organizationId, user.activeOrganizationId), + root.variant === "space" + ? eq(folders.spaceId, root.spaceId) + : isNull(folders.spaceId) + ) + ) + ); + + type FolderWithChildren = { + id: string; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + parentId: string | null; + organizationId: string; + videoCount: number; + children: FolderWithChildren[]; + }; + + const folderMap = new Map(); + const rootFolders: FolderWithChildren[] = []; + + allFolders.forEach((folder) => { + folderMap.set(folder.id, { ...folder, children: [] }); + }); + + allFolders.forEach((folder) => { + const folderWithChildren = folderMap.get(folder.id); + + if (folder.parentId) { + const parent = folderMap.get(folder.parentId); + if (parent && folderWithChildren) { + parent.children.push(folderWithChildren); + } + } else { + if (folderWithChildren) { + rootFolders.push(folderWithChildren); + } + } + }); + + return rootFolders; +}); + +export const moveVideosToFolder = Effect.fn(function* ( + videoIds: Video.VideoId[], + targetFolderId: Folder.FolderId | null, + root?: + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + if (videoIds.length === 0) throw new Error("No videos to move"); + + const db = yield* Database; + const user = yield* CurrentUser; + + if (!user.activeOrganizationId) throw new Error("No active organization"); + + const existingVideos = yield* db.execute((db) => + db + .select({ + id: videos.id, + folderId: videos.folderId, + ownerId: videos.ownerId, + }) + .from(videos) + .where(and(inArray(videos.id, videoIds), eq(videos.ownerId, user.id))) + ); + + if (existingVideos.length !== videoIds.length) { + throw new Error( + "Some videos not found or you don't have permission to move them" + ); + } + + if (targetFolderId) { + const targetFolder = yield* getFolderById(targetFolderId); + + if (targetFolder.organizationId !== user.activeOrganizationId) { + throw new Error("Target folder not found or you don't have access to it"); + } + + if (root?.variant === "space" && targetFolder.spaceId !== root.spaceId) { + throw new Error("Target folder does not belong to the specified space"); + } + + if (root?.variant !== "space" && targetFolder.spaceId !== null) { + throw new Error( + "Target folder is scoped to a space and cannot be used here" + ); + } + } + + let originalFolderIds: (string | null)[] = []; + const videoCountDeltas: Record = {}; + + if (root?.variant === "space") { + const spaceRows = yield* db.execute((db) => + db + .select({ + folderId: spaceVideos.folderId, + videoId: spaceVideos.videoId, + }) + .from(spaceVideos) + .where( + and( + eq(spaceVideos.spaceId, root.spaceId), + inArray(spaceVideos.videoId, videoIds) + ) + ) + ); + const spaceVideoIds = new Set(spaceRows.map((row) => row.videoId)); + const missingVideoIds = videoIds.filter((id) => !spaceVideoIds.has(id)); + if (missingVideoIds.length > 0) { + throw new Error( + "Some videos are not in the specified space or you don't have permission to move them" + ); + } + + const folderCounts = new Map(); + spaceRows.forEach((row) => { + if (row.folderId) { + folderCounts.set( + row.folderId, + (folderCounts.get(row.folderId) || 0) + 1 + ); + } + }); + + folderCounts.forEach((count, folderId) => { + videoCountDeltas[folderId] = -count; + }); + + originalFolderIds = [...folderCounts.keys()]; + + yield* db.execute((db) => + db + .update(spaceVideos) + .set({ folderId: targetFolderId }) + .where( + and( + eq(spaceVideos.spaceId, root.spaceId), + inArray(spaceVideos.videoId, videoIds) + ) + ) + ); + } else { + const folderCounts = new Map(); + existingVideos.forEach((video) => { + if (video.folderId) { + folderCounts.set( + video.folderId, + (folderCounts.get(video.folderId) || 0) + 1 + ); + } + }); + + folderCounts.forEach((count, folderId) => { + videoCountDeltas[folderId] = -count; + }); + + originalFolderIds = [...folderCounts.keys()]; + + yield* db.execute((db) => + db + .update(videos) + .set({ + folderId: targetFolderId, + updatedAt: new Date(), + }) + .where(inArray(videos.id, videoIds)) + ); + } + + if (targetFolderId) { + videoCountDeltas[targetFolderId] = + (videoCountDeltas[targetFolderId] || 0) + videoIds.length; + } + + return { + movedCount: videoIds.length, + originalFolderIds, + targetFolderId, + videoCountDeltas, + }; });