diff --git a/packages/admin/src/api/storage.ts b/packages/admin/src/api/storage.ts new file mode 100644 index 0000000..168b156 --- /dev/null +++ b/packages/admin/src/api/storage.ts @@ -0,0 +1,73 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import type { StorageFolder, StorageFolderPermission, StorageFile } from '@/types/storage' +import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' + +// --- Folders --- + +export const storageFolderKeys = { + all: ['storage-folders'] as const, + tree: ['storage-folders', 'tree'] as const, + children: (parentId: string | null) => ['storage-folders', 'children', parentId] as const, + detail: (id: string) => ['storage-folders', 'detail', id] as const, + permissions: (id: string) => ['storage-folders', id, 'permissions'] as const, +} + +export function storageFolderTreeOptions() { + return queryOptions({ + queryKey: storageFolderKeys.tree, + queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders/tree'), + }) +} + +export function storageFolderChildrenOptions(parentId: string | null) { + return queryOptions({ + queryKey: storageFolderKeys.children(parentId), + queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders', parentId ? { parentId } : {}), + }) +} + +export function storageFolderDetailOptions(id: string) { + return queryOptions({ + queryKey: storageFolderKeys.detail(id), + queryFn: () => api.get(`/v1/storage/folders/${id}`), + enabled: !!id, + }) +} + +export function storageFolderPermissionsOptions(id: string) { + return queryOptions({ + queryKey: storageFolderKeys.permissions(id), + queryFn: () => api.get<{ data: StorageFolderPermission[] }>(`/v1/storage/folders/${id}/permissions`), + enabled: !!id, + }) +} + +export const storageFolderMutations = { + create: (data: Record) => api.post('/v1/storage/folders', data), + update: (id: string, data: Record) => api.patch(`/v1/storage/folders/${id}`, data), + delete: (id: string) => api.del(`/v1/storage/folders/${id}`), + addPermission: (folderId: string, data: Record) => api.post(`/v1/storage/folders/${folderId}/permissions`, data), + removePermission: (permId: string) => api.del(`/v1/storage/folder-permissions/${permId}`), +} + +// --- Files --- + +export const storageFileKeys = { + all: (folderId: string) => ['storage-files', folderId] as const, + list: (folderId: string, params: PaginationInput) => ['storage-files', folderId, params] as const, + search: (q: string) => ['storage-files', 'search', q] as const, +} + +export function storageFileListOptions(folderId: string, params: PaginationInput) { + return queryOptions({ + queryKey: storageFileKeys.list(folderId, params), + queryFn: () => api.get>(`/v1/storage/folders/${folderId}/files`, params), + enabled: !!folderId, + }) +} + +export const storageFileMutations = { + delete: (id: string) => api.del(`/v1/storage/files/${id}`), + getSignedUrl: (id: string) => api.get<{ url: string }>(`/v1/storage/files/${id}/signed-url`), +} diff --git a/packages/admin/src/components/storage/file-icons.tsx b/packages/admin/src/components/storage/file-icons.tsx new file mode 100644 index 0000000..c821023 --- /dev/null +++ b/packages/admin/src/components/storage/file-icons.tsx @@ -0,0 +1,29 @@ +import { FileText, Image, FileSpreadsheet, File, FileType, Film } from 'lucide-react' + +const ICON_MAP: Record = { + 'application/pdf': { icon: FileText, color: 'text-red-500' }, + 'image/jpeg': { icon: Image, color: 'text-blue-500' }, + 'image/png': { icon: Image, color: 'text-blue-500' }, + 'image/webp': { icon: Image, color: 'text-blue-500' }, + 'image/gif': { icon: Image, color: 'text-blue-500' }, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: FileType, color: 'text-blue-600' }, + 'application/msword': { icon: FileType, color: 'text-blue-600' }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: FileSpreadsheet, color: 'text-green-600' }, + 'application/vnd.ms-excel': { icon: FileSpreadsheet, color: 'text-green-600' }, + 'text/csv': { icon: FileSpreadsheet, color: 'text-green-600' }, + 'text/plain': { icon: FileText, color: 'text-muted-foreground' }, + 'video/mp4': { icon: Film, color: 'text-purple-500' }, +} + +export function FileIcon({ contentType, className = 'h-8 w-8' }: { contentType: string; className?: string }) { + const match = ICON_MAP[contentType] ?? { icon: File, color: 'text-muted-foreground' } + const Icon = match.icon + return +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} diff --git a/packages/admin/src/components/storage/folder-tree.tsx b/packages/admin/src/components/storage/folder-tree.tsx new file mode 100644 index 0000000..ab737e5 --- /dev/null +++ b/packages/admin/src/components/storage/folder-tree.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { ChevronRight, ChevronDown, Folder, FolderOpen, Lock } from 'lucide-react' +import type { StorageFolder } from '@/types/storage' + +interface FolderTreeProps { + folders: StorageFolder[] + selectedFolderId: string | null + onSelect: (folderId: string | null) => void +} + +interface TreeNode { + folder: StorageFolder + children: TreeNode[] +} + +function buildTree(folders: StorageFolder[]): TreeNode[] { + const map = new Map() + const roots: TreeNode[] = [] + + for (const folder of folders) { + map.set(folder.id, { folder, children: [] }) + } + + for (const folder of folders) { + const node = map.get(folder.id)! + if (folder.parentId && map.has(folder.parentId)) { + map.get(folder.parentId)!.children.push(node) + } else { + roots.push(node) + } + } + + return roots +} + +export function FolderTree({ folders, selectedFolderId, onSelect }: FolderTreeProps) { + const tree = buildTree(folders) + + return ( +
+ + {tree.map((node) => ( + + ))} +
+ ) +} + +function TreeItem({ node, depth, selectedFolderId, onSelect }: { node: TreeNode; depth: number; selectedFolderId: string | null; onSelect: (id: string) => void }) { + const [expanded, setExpanded] = useState(depth < 2) + const hasChildren = node.children.length > 0 + const isSelected = selectedFolderId === node.folder.id + + return ( +
+ + {expanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index 1ba2840..8fce3e2 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authentic import { Route as AuthenticatedRepairsIndexRouteImport } from './routes/_authenticated/repairs/index' import { Route as AuthenticatedRepairBatchesIndexRouteImport } from './routes/_authenticated/repair-batches/index' import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index' +import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index' import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index' import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new' import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId' @@ -88,6 +89,11 @@ const AuthenticatedMembersIndexRoute = path: '/members/', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({ + id: '/files/', + path: '/files/', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedAccountsIndexRoute = AuthenticatedAccountsIndexRouteImport.update({ id: '/accounts/', @@ -200,6 +206,7 @@ export interface FileRoutesByFullPath { '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/new': typeof AuthenticatedRolesNewRoute '/accounts/': typeof AuthenticatedAccountsIndexRoute + '/files/': typeof AuthenticatedFilesIndexRoute '/members/': typeof AuthenticatedMembersIndexRoute '/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute '/repairs/': typeof AuthenticatedRepairsIndexRoute @@ -226,6 +233,7 @@ export interface FileRoutesByTo { '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/new': typeof AuthenticatedRolesNewRoute '/accounts': typeof AuthenticatedAccountsIndexRoute + '/files': typeof AuthenticatedFilesIndexRoute '/members': typeof AuthenticatedMembersIndexRoute '/repair-batches': typeof AuthenticatedRepairBatchesIndexRoute '/repairs': typeof AuthenticatedRepairsIndexRoute @@ -255,6 +263,7 @@ export interface FileRoutesById { '/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute '/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute + '/_authenticated/files/': typeof AuthenticatedFilesIndexRoute '/_authenticated/members/': typeof AuthenticatedMembersIndexRoute '/_authenticated/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute '/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute @@ -284,6 +293,7 @@ export interface FileRouteTypes { | '/roles/$roleId' | '/roles/new' | '/accounts/' + | '/files/' | '/members/' | '/repair-batches/' | '/repairs/' @@ -310,6 +320,7 @@ export interface FileRouteTypes { | '/roles/$roleId' | '/roles/new' | '/accounts' + | '/files' | '/members' | '/repair-batches' | '/repairs' @@ -338,6 +349,7 @@ export interface FileRouteTypes { | '/_authenticated/roles/$roleId' | '/_authenticated/roles/new' | '/_authenticated/accounts/' + | '/_authenticated/files/' | '/_authenticated/members/' | '/_authenticated/repair-batches/' | '/_authenticated/repairs/' @@ -426,6 +438,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/files/': { + id: '/_authenticated/files/' + path: '/files' + fullPath: '/files/' + preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/accounts/': { id: '/_authenticated/accounts/' path: '/accounts' @@ -584,6 +603,7 @@ interface AuthenticatedRouteChildren { AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute + AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute AuthenticatedRepairBatchesIndexRoute: typeof AuthenticatedRepairBatchesIndexRoute AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute @@ -608,6 +628,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute, AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute, AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute, + AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute, AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute, AuthenticatedRepairBatchesIndexRoute: AuthenticatedRepairBatchesIndexRoute, AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute, diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index ef65bb1..f94f9be 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store' import { myPermissionsOptions } from '@/api/rbac' import { Avatar } from '@/components/shared/avatar-upload' import { Button } from '@/components/ui/button' -import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList } from 'lucide-react' +import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen } from 'lucide-react' export const Route = createFileRoute('/_authenticated')({ beforeLoad: () => { @@ -87,6 +87,7 @@ function AuthenticatedLayout() { )} )} + } label="Files" /> {canViewUsers && (
Admin diff --git a/packages/admin/src/routes/_authenticated/files/index.tsx b/packages/admin/src/routes/_authenticated/files/index.tsx new file mode 100644 index 0000000..d8a3a3d --- /dev/null +++ b/packages/admin/src/routes/_authenticated/files/index.tsx @@ -0,0 +1,288 @@ +import { useState, useRef } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + storageFolderTreeOptions, storageFolderDetailOptions, storageFolderMutations, storageFolderKeys, + storageFileListOptions, storageFileMutations, storageFileKeys, +} from '@/api/storage' +import { usePagination } from '@/hooks/use-pagination' +import { FolderTree } from '@/components/storage/folder-tree' +import { FileIcon, formatFileSize } from '@/components/storage/file-icons' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { FolderPlus, Upload, Search, Folder, ChevronRight, MoreVertical, Trash2, Download } from 'lucide-react' +import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth.store' +import type { StorageFolder, StorageFile } from '@/types/storage' + +export const Route = createFileRoute('/_authenticated/files/')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 50, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), + component: FileManagerPage, +}) + +function FileManagerPage() { + const queryClient = useQueryClient() + const token = useAuthStore((s) => s.token) + const [selectedFolderId, setSelectedFolderId] = useState(null) + const [newFolderOpen, setNewFolderOpen] = useState(false) + const [newFolderName, setNewFolderName] = useState('') + const fileInputRef = useRef(null) + const { params } = usePagination() + + const { data: treeData, isLoading: treeLoading } = useQuery(storageFolderTreeOptions()) + const { data: folderDetail } = useQuery(storageFolderDetailOptions(selectedFolderId ?? '')) + const { data: filesData, isLoading: filesLoading } = useQuery( + storageFileListOptions(selectedFolderId ?? '', { ...params, limit: 50 }), + ) + const { data: subFoldersData } = useQuery({ + queryKey: storageFolderKeys.children(selectedFolderId), + queryFn: () => { + const allFolders = treeData?.data ?? [] + return { data: allFolders.filter((f) => f.parentId === selectedFolderId) } + }, + enabled: !!treeData, + }) + + const createFolderMutation = useMutation({ + mutationFn: storageFolderMutations.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storageFolderKeys.all }) + toast.success('Folder created') + setNewFolderOpen(false) + setNewFolderName('') + }, + onError: (err) => toast.error(err.message), + }) + + const deleteFolderMutation = useMutation({ + mutationFn: storageFolderMutations.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storageFolderKeys.all }) + setSelectedFolderId(null) + toast.success('Folder deleted') + }, + onError: (err) => toast.error(err.message), + }) + + const deleteFileMutation = useMutation({ + mutationFn: storageFileMutations.delete, + onSuccess: () => { + if (selectedFolderId) queryClient.invalidateQueries({ queryKey: storageFileKeys.all(selectedFolderId) }) + toast.success('File deleted') + }, + onError: (err) => toast.error(err.message), + }) + + async function handleFileUpload(e: React.ChangeEvent) { + if (!selectedFolderId || !e.target.files?.length) return + const files = Array.from(e.target.files) + + for (const file of files) { + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch(`/v1/storage/folders/${selectedFolderId}/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + toast.error(`Upload failed: ${(err as any).error?.message ?? file.name}`) + } + } catch { + toast.error(`Upload failed: ${file.name}`) + } + } + + queryClient.invalidateQueries({ queryKey: storageFileKeys.all(selectedFolderId) }) + toast.success(`${files.length} file(s) uploaded`) + e.target.value = '' + } + + async function handleDownload(file: StorageFile) { + try { + const { url } = await storageFileMutations.getSignedUrl(file.id) + window.open(url, '_blank') + } catch { + toast.error('Download failed') + } + } + + function handleCreateFolder(e: React.FormEvent) { + e.preventDefault() + if (!newFolderName.trim()) return + createFolderMutation.mutate({ name: newFolderName.trim(), parentId: selectedFolderId ?? undefined }) + } + + const folders = treeData?.data ?? [] + const files = filesData?.data ?? [] + const subFolders = subFoldersData?.data ?? [] + const breadcrumbs = folderDetail?.breadcrumbs ?? [] + + return ( +
+ {/* Left Panel — Folder Tree */} +
+
+

Folders

+ + + + + + New Folder +
+
+ + setNewFolderName(e.target.value)} placeholder="e.g. HR Documents" autoFocus /> +
+ {selectedFolderId && ( +

+ Creating inside: {breadcrumbs.map((b) => b.name).join(' / ') || 'Root'} +

+ )} + +
+
+
+
+ + {treeLoading ? ( +
{Array.from({ length: 5 }).map((_, i) => )}
+ ) : ( + + )} +
+ + {/* Right Panel — Files */} +
+ {/* Toolbar */} +
+ {/* Breadcrumbs */} +
+ + {breadcrumbs.map((crumb) => ( + + + + + ))} +
+ + {selectedFolderId && ( + <> + + + + )} +
+ + {/* Content */} +
+ {!selectedFolderId ? ( +
+ +

Select a folder to view files

+

Or create a new folder to get started

+
+ ) : filesLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => )} +
+ ) : ( + <> + {/* Sub-folders */} + {subFolders.length > 0 && ( +
+

Folders

+
+ {subFolders.map((folder) => ( + + ))} +
+
+ )} + + {/* Files */} + {files.length > 0 && ( +
+ {subFolders.length > 0 &&

Files

} +
+ {files.map((file) => ( +
+ + + + + + + + handleDownload(file)}> + Download + + deleteFileMutation.mutate(file.id)}> + Delete + + + +
+ ))} +
+
+ )} + + {files.length === 0 && subFolders.length === 0 && ( +
+ +

This folder is empty

+

Upload files or create sub-folders

+
+ )} + + )} +
+
+
+ ) +} diff --git a/packages/admin/src/types/storage.ts b/packages/admin/src/types/storage.ts new file mode 100644 index 0000000..9b03402 --- /dev/null +++ b/packages/admin/src/types/storage.ts @@ -0,0 +1,31 @@ +export interface StorageFolder { + id: string + parentId: string | null + name: string + path: string + createdBy: string | null + isPublic: boolean + createdAt: string + updatedAt: string + breadcrumbs?: { id: string; name: string }[] +} + +export interface StorageFolderPermission { + id: string + folderId: string + roleId: string | null + userId: string | null + accessLevel: 'view' | 'edit' | 'admin' + createdAt: string +} + +export interface StorageFile { + id: string + folderId: string + filename: string + path: string + contentType: string + sizeBytes: number + uploadedBy: string | null + createdAt: string +} diff --git a/packages/backend/src/db/migrations/0022_shared_file_storage.sql b/packages/backend/src/db/migrations/0022_shared_file_storage.sql new file mode 100644 index 0000000..86fba1f --- /dev/null +++ b/packages/backend/src/db/migrations/0022_shared_file_storage.sql @@ -0,0 +1,32 @@ +CREATE TYPE "storage_folder_access" AS ENUM ('view', 'edit', 'admin'); + +CREATE TABLE "storage_folder" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "parent_id" uuid, + "name" varchar(255) NOT NULL, + "path" text NOT NULL DEFAULT '/', + "created_by" uuid REFERENCES "user"("id"), + "is_public" boolean NOT NULL DEFAULT true, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE TABLE "storage_folder_permission" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "folder_id" uuid NOT NULL REFERENCES "storage_folder"("id") ON DELETE CASCADE, + "role_id" uuid REFERENCES "role"("id"), + "user_id" uuid REFERENCES "user"("id"), + "access_level" "storage_folder_access" NOT NULL DEFAULT 'view', + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE TABLE "storage_file" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "folder_id" uuid NOT NULL REFERENCES "storage_folder"("id") ON DELETE CASCADE, + "filename" varchar(255) NOT NULL, + "path" varchar(1000) NOT NULL, + "content_type" varchar(100) NOT NULL, + "size_bytes" integer NOT NULL, + "uploaded_by" uuid REFERENCES "user"("id"), + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 15bd167..4af7ee9 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1774810000000, "tag": "0021_remove_company_scoping", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1774820000000, + "tag": "0022_shared_file_storage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/storage.ts b/packages/backend/src/db/schema/storage.ts new file mode 100644 index 0000000..d123c4e --- /dev/null +++ b/packages/backend/src/db/schema/storage.ts @@ -0,0 +1,55 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + boolean, + integer, + pgEnum, +} from 'drizzle-orm/pg-core' +import { users } from './users.js' +import { roles } from './rbac.js' + +export const storageFolderAccessEnum = pgEnum('storage_folder_access', ['view', 'edit', 'admin']) + +export const storageFolders = pgTable('storage_folder', { + id: uuid('id').primaryKey().defaultRandom(), + parentId: uuid('parent_id'), + name: varchar('name', { length: 255 }).notNull(), + path: text('path').notNull().default('/'), + createdBy: uuid('created_by').references(() => users.id), + isPublic: boolean('is_public').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const storageFolderPermissions = pgTable('storage_folder_permission', { + id: uuid('id').primaryKey().defaultRandom(), + folderId: uuid('folder_id') + .notNull() + .references(() => storageFolders.id, { onDelete: 'cascade' }), + roleId: uuid('role_id').references(() => roles.id), + userId: uuid('user_id').references(() => users.id), + accessLevel: storageFolderAccessEnum('access_level').notNull().default('view'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const storageFiles = pgTable('storage_file', { + id: uuid('id').primaryKey().defaultRandom(), + folderId: uuid('folder_id') + .notNull() + .references(() => storageFolders.id, { onDelete: 'cascade' }), + filename: varchar('filename', { length: 255 }).notNull(), + path: varchar('path', { length: 1000 }).notNull(), + contentType: varchar('content_type', { length: 100 }).notNull(), + sizeBytes: integer('size_bytes').notNull(), + uploadedBy: uuid('uploaded_by').references(() => users.id), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type StorageFolder = typeof storageFolders.$inferSelect +export type StorageFolderInsert = typeof storageFolders.$inferInsert +export type StorageFolderPermission = typeof storageFolderPermissions.$inferSelect +export type StorageFile = typeof storageFiles.$inferSelect +export type StorageFileInsert = typeof storageFiles.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index b201c9c..b49bf23 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -16,6 +16,7 @@ import { lookupRoutes } from './routes/v1/lookups.js' import { fileRoutes } from './routes/v1/files.js' import { rbacRoutes } from './routes/v1/rbac.js' import { repairRoutes } from './routes/v1/repairs.js' +import { storageRoutes } from './routes/v1/storage.js' import { RbacService } from './services/rbac.service.js' export async function buildApp() { @@ -67,6 +68,7 @@ export async function buildApp() { await app.register(fileRoutes, { prefix: '/v1' }) await app.register(rbacRoutes, { prefix: '/v1' }) await app.register(repairRoutes, { prefix: '/v1' }) + await app.register(storageRoutes, { prefix: '/v1' }) // Auto-seed system permissions on startup app.addHook('onReady', async () => { diff --git a/packages/backend/src/routes/v1/storage.ts b/packages/backend/src/routes/v1/storage.ts new file mode 100644 index 0000000..0aeb6b6 --- /dev/null +++ b/packages/backend/src/routes/v1/storage.ts @@ -0,0 +1,185 @@ +import type { FastifyPluginAsync } from 'fastify' +import multipart from '@fastify/multipart' +import { PaginationSchema } from '@forte/shared/schemas' +import { StorageFolderService, StorageFileService, StoragePermissionService } from '../../services/storage.service.js' +import { ValidationError } from '../../lib/errors.js' + +export const storageRoutes: FastifyPluginAsync = async (app) => { + await app.register(multipart, { + limits: { fileSize: 50 * 1024 * 1024, files: 1 }, + }) + + // --- Folders --- + + app.post('/storage/folders', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { + const { name, parentId, isPublic } = request.body as { name?: string; parentId?: string; isPublic?: boolean } + if (!name?.trim()) throw new ValidationError('Folder name is required') + + // Check parent access if creating subfolder + if (parentId) { + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentId, request.user.id) + if (!accessLevel || accessLevel === 'view') { + return reply.status(403).send({ error: { message: 'No edit access to parent folder', statusCode: 403 } }) + } + } + + const folder = await StorageFolderService.create(app.db, { name: name.trim(), parentId, isPublic, createdBy: request.user.id }) + return reply.status(201).send(folder) + }) + + app.get('/storage/folders', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { parentId } = request.query as { parentId?: string } + const folders = parentId + ? await StorageFolderService.listChildren(app.db, parentId) + : await StorageFolderService.listChildren(app.db, null) + return reply.send({ data: folders }) + }) + + app.get('/storage/folders/tree', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const folders = await StorageFolderService.listAllAccessible(app.db, request.user.id) + return reply.send({ data: folders }) + }) + + app.get('/storage/folders/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const folder = await StorageFolderService.getById(app.db, id) + if (!folder) return reply.status(404).send({ error: { message: 'Folder not found', statusCode: 404 } }) + + const hasAccess = await StoragePermissionService.canAccess(app.db, id, request.user.id) + if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) + + const breadcrumbs = await StorageFolderService.getBreadcrumbs(app.db, id) + return reply.send({ ...folder, breadcrumbs }) + }) + + app.patch('/storage/folders/:id', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const { name, isPublic } = request.body as { name?: string; isPublic?: boolean } + + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id) + if (!accessLevel || accessLevel === 'view') { + return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } }) + } + + const folder = await StorageFolderService.update(app.db, id, { name, isPublic }) + if (!folder) return reply.status(404).send({ error: { message: 'Folder not found', statusCode: 404 } }) + return reply.send(folder) + }) + + app.delete('/storage/folders/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { + const { id } = request.params as { id: string } + + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id) + if (accessLevel !== 'admin') { + return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } }) + } + + const folder = await StorageFolderService.delete(app.db, id) + if (!folder) return reply.status(404).send({ error: { message: 'Folder not found', statusCode: 404 } }) + return reply.send(folder) + }) + + // --- Folder Permissions --- + + app.get('/storage/folders/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const permissions = await StoragePermissionService.listPermissions(app.db, id) + return reply.send({ data: permissions }) + }) + + app.post('/storage/folders/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const { roleId, userId, accessLevel } = request.body as { roleId?: string; userId?: string; accessLevel?: string } + + const myAccess = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id) + if (myAccess !== 'admin') { + return reply.status(403).send({ error: { message: 'Admin access required to manage permissions', statusCode: 403 } }) + } + + if (!roleId && !userId) throw new ValidationError('Either roleId or userId is required') + if (!accessLevel || !['view', 'edit', 'admin'].includes(accessLevel)) throw new ValidationError('accessLevel must be view, edit, or admin') + + const perm = await StoragePermissionService.setPermission(app.db, id, roleId, userId, accessLevel) + return reply.status(201).send(perm) + }) + + app.delete('/storage/folder-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const perm = await StoragePermissionService.removePermission(app.db, id) + if (!perm) return reply.status(404).send({ error: { message: 'Permission not found', statusCode: 404 } }) + return reply.send(perm) + }) + + // --- Files --- + + app.post('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { + const { folderId } = request.params as { folderId: string } + + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, folderId, request.user.id) + if (!accessLevel || accessLevel === 'view') { + return reply.status(403).send({ error: { message: 'No edit access to this folder', statusCode: 403 } }) + } + + const data = await request.file() + if (!data) throw new ValidationError('No file provided') + + const buffer = await data.toBuffer() + + const file = await StorageFileService.upload(app.db, app.storage, { + folderId, + data: buffer, + filename: data.filename, + contentType: data.mimetype, + uploadedBy: request.user.id, + }) + + return reply.status(201).send(file) + }) + + app.get('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { folderId } = request.params as { folderId: string } + + const hasAccess = await StoragePermissionService.canAccess(app.db, folderId, request.user.id) + if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) + + const params = PaginationSchema.parse(request.query) + const result = await StorageFileService.listByFolder(app.db, folderId, params) + return reply.send(result) + }) + + app.delete('/storage/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const file = await StorageFileService.getById(app.db, id) + if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) + + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, file.folderId, request.user.id) + if (!accessLevel || accessLevel === 'view') { + return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } }) + } + + const deleted = await StorageFileService.delete(app.db, app.storage, id) + return reply.send(deleted) + }) + + app.get('/storage/files/:id/signed-url', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const file = await StorageFileService.getById(app.db, id) + if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) + + const hasAccess = await StoragePermissionService.canAccess(app.db, file.folderId, request.user.id) + if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) + + const token = app.jwt.sign({ path: file.path, purpose: 'file-access' } as any, { expiresIn: '15m' }) + const signedUrl = `/v1/files/s/${file.path}?token=${token}` + return reply.send({ url: signedUrl }) + }) + + app.get('/storage/files/search', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { + const { q } = request.query as { q?: string } + if (!q) return reply.send({ data: [], pagination: { page: 1, limit: 25, total: 0, totalPages: 0 } }) + + const params = PaginationSchema.parse(request.query) + const result = await StorageFileService.search(app.db, q, params) + return reply.send(result) + }) +} diff --git a/packages/backend/src/services/storage.service.ts b/packages/backend/src/services/storage.service.ts new file mode 100644 index 0000000..a489780 --- /dev/null +++ b/packages/backend/src/services/storage.service.ts @@ -0,0 +1,277 @@ +import { eq, and, count, ilike, inArray, or, isNull, type Column } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { storageFolders, storageFolderPermissions, storageFiles } from '../db/schema/storage.js' +import { userRoles } from '../db/schema/rbac.js' +import type { StorageProvider } from '../storage/index.js' +import { randomUUID } from 'crypto' +import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' +import type { PaginationInput } from '@forte/shared/schemas' + +function getExtension(contentType: string): string { + const map: Record = { + 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', + 'application/pdf': 'pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + 'application/msword': 'doc', 'application/vnd.ms-excel': 'xls', + 'text/plain': 'txt', 'text/csv': 'csv', + } + return map[contentType] ?? contentType.split('/')[1] ?? 'bin' +} + +// --- Permission Service --- + +export const StoragePermissionService = { + async canAccess(db: PostgresJsDatabase, folderId: string, userId: string): Promise { + // Check if folder is public + const [folder] = await db.select({ isPublic: storageFolders.isPublic }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (!folder) return false + if (folder.isPublic) return true + + // Check direct user permission + const [userPerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions) + .where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId))) + .limit(1) + if (userPerm) return true + + // Check role-based permission + const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) + if (userRoleIds.length > 0) { + const roleIds = userRoleIds.map((r) => r.roleId) + const [rolePerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions) + .where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds))) + .limit(1) + if (rolePerm) return true + } + + // Check parent folder (inherited permissions) + const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (parentFolder?.parentId) { + return this.canAccess(db, parentFolder.parentId, userId) + } + + return false + }, + + async getAccessLevel(db: PostgresJsDatabase, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | null> { + const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (!folder) return null + + // Creator always has admin + if (folder.createdBy === userId) return 'admin' + + // Check direct user permission + const [userPerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions) + .where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId))) + .limit(1) + if (userPerm) return userPerm.accessLevel + + // Check role-based permission + const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) + if (userRoleIds.length > 0) { + const roleIds = userRoleIds.map((r) => r.roleId) + const [rolePerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions) + .where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds))) + .limit(1) + if (rolePerm) return rolePerm.accessLevel + } + + // Check parent (inherited) + const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (parentFolder?.parentId) { + return this.getAccessLevel(db, parentFolder.parentId, userId) + } + + // Public folders give view access + if (folder.isPublic) return 'view' + + return null + }, + + async listPermissions(db: PostgresJsDatabase, folderId: string) { + return db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.folderId, folderId)) + }, + + async setPermission(db: PostgresJsDatabase, folderId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) { + // Remove existing permission for this role/user on this folder + if (roleId) { + await db.delete(storageFolderPermissions).where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.roleId, roleId))) + } + if (userId) { + await db.delete(storageFolderPermissions).where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId))) + } + + const [perm] = await db.insert(storageFolderPermissions).values({ + folderId, + roleId: roleId ?? null, + userId: userId ?? null, + accessLevel: accessLevel as any, + }).returning() + return perm + }, + + async removePermission(db: PostgresJsDatabase, permissionId: string) { + const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning() + return perm ?? null + }, +} + +// --- Folder Service --- + +export const StorageFolderService = { + async create(db: PostgresJsDatabase, input: { name: string; parentId?: string; isPublic?: boolean; createdBy?: string }) { + // Build materialized path + let path = '/' + if (input.parentId) { + const [parent] = await db.select({ path: storageFolders.path, name: storageFolders.name }).from(storageFolders).where(eq(storageFolders.id, input.parentId)).limit(1) + if (parent) path = `${parent.path}${parent.name}/` + } + + const [folder] = await db.insert(storageFolders).values({ + name: input.name, + parentId: input.parentId ?? null, + path, + isPublic: input.isPublic ?? true, + createdBy: input.createdBy ?? null, + }).returning() + return folder + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [folder] = await db.select().from(storageFolders).where(eq(storageFolders.id, id)).limit(1) + return folder ?? null + }, + + async listChildren(db: PostgresJsDatabase, parentId: string | null) { + const where = parentId ? eq(storageFolders.parentId, parentId) : isNull(storageFolders.parentId) + return db.select().from(storageFolders).where(where).orderBy(storageFolders.name) + }, + + async listAllAccessible(db: PostgresJsDatabase, userId: string) { + // Get all folders and filter by access — for the tree view + const allFolders = await db.select().from(storageFolders).orderBy(storageFolders.name) + + // For each folder, check access (this is simplified — in production you'd optimize with a single query) + const accessible = [] + for (const folder of allFolders) { + if (folder.isPublic || folder.createdBy === userId) { + accessible.push(folder) + continue + } + const hasAccess = await StoragePermissionService.canAccess(db, folder.id, userId) + if (hasAccess) accessible.push(folder) + } + return accessible + }, + + async update(db: PostgresJsDatabase, id: string, input: { name?: string; isPublic?: boolean }) { + const [folder] = await db.update(storageFolders).set({ ...input, updatedAt: new Date() }).where(eq(storageFolders.id, id)).returning() + return folder ?? null + }, + + async delete(db: PostgresJsDatabase, id: string) { + // CASCADE handles permissions and files via FK + const [folder] = await db.delete(storageFolders).where(eq(storageFolders.id, id)).returning() + return folder ?? null + }, + + async getBreadcrumbs(db: PostgresJsDatabase, folderId: string): Promise<{ id: string; name: string }[]> { + const crumbs: { id: string; name: string }[] = [] + let currentId: string | null = folderId + while (currentId) { + const [folder] = await db.select({ id: storageFolders.id, name: storageFolders.name, parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentId)).limit(1) + if (!folder) break + crumbs.unshift({ id: folder.id, name: folder.name }) + currentId = folder.parentId + } + return crumbs + }, +} + +// --- File Service --- + +export const StorageFileService = { + async upload(db: PostgresJsDatabase, storage: StorageProvider, input: { + folderId: string + data: Buffer + filename: string + contentType: string + uploadedBy?: string + }) { + const fileId = randomUUID() + const ext = getExtension(input.contentType) + const storagePath = `storage/${input.folderId}/${fileId}.${ext}` + + await storage.put(storagePath, input.data, input.contentType) + + const [file] = await db.insert(storageFiles).values({ + id: fileId, + folderId: input.folderId, + filename: input.filename, + path: storagePath, + contentType: input.contentType, + sizeBytes: input.data.length, + uploadedBy: input.uploadedBy ?? null, + }).returning() + return file + }, + + async listByFolder(db: PostgresJsDatabase, folderId: string, params: PaginationInput) { + const baseWhere = eq(storageFiles.folderId, folderId) + const searchCondition = params.q + ? buildSearchCondition(params.q, [storageFiles.filename]) + : undefined + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + filename: storageFiles.filename, + size_bytes: storageFiles.sizeBytes, + created_at: storageFiles.createdAt, + } + + let query = db.select().from(storageFiles).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, storageFiles.filename) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(storageFiles).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [file] = await db.select().from(storageFiles).where(eq(storageFiles.id, id)).limit(1) + return file ?? null + }, + + async delete(db: PostgresJsDatabase, storage: StorageProvider, id: string) { + const file = await this.getById(db, id) + if (!file) return null + await storage.delete(file.path) + const [deleted] = await db.delete(storageFiles).where(eq(storageFiles.id, id)).returning() + return deleted ?? null + }, + + async search(db: PostgresJsDatabase, query: string, params: PaginationInput) { + const searchCondition = buildSearchCondition(query, [storageFiles.filename]) + if (!searchCondition) return paginatedResponse([], 0, params.page, params.limit) + + const sortableColumns: Record = { + filename: storageFiles.filename, + created_at: storageFiles.createdAt, + } + + let q = db.select().from(storageFiles).where(searchCondition).$dynamic() + q = withSort(q, params.sort, params.order, sortableColumns, storageFiles.filename) + q = withPagination(q, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + q, + db.select({ total: count() }).from(storageFiles).where(searchCondition), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, +}