mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-20 12:19:46 -06:00
feat(documents): added delete documents restoration (#61)
This commit is contained in:
committed by
GitHub
parent
5c875b3e6f
commit
cad6ff4e51
@@ -1,4 +1,6 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/solid-table';
|
||||
import type { Document } from '../documents.types';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
@@ -7,28 +9,37 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createQuery, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { type Component, createSignal, For, Match, Show, Switch } from 'solid-js';
|
||||
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
|
||||
import { getDocumentIcon } from '../document.models';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
import { DocumentManagementDropdown } from './document-management-dropdown.component';
|
||||
|
||||
export const DocumentsPaginatedList: Component<{ organizationId: string }> = (props) => {
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
type Pagination = {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'documents', getPagination()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: props.organizationId,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
export const createdAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Created at</span>),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
};
|
||||
|
||||
export const deletedAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Deleted at</span>),
|
||||
accessorKey: 'deletedAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
};
|
||||
|
||||
export const DocumentsPaginatedList: Component<{
|
||||
documents: Document[];
|
||||
documentsCount: number;
|
||||
getPagination: Accessor<Pagination>;
|
||||
setPagination: Setter<Pagination>;
|
||||
extraColumns?: ColumnDef<Document>[];
|
||||
}> = (props) => {
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.documents ?? [];
|
||||
return props.documents ?? [];
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
@@ -41,7 +52,7 @@ export const DocumentsPaginatedList: Component<{ organizationId: string }> = (pr
|
||||
|
||||
<div class="flex-1 flex flex-col gap-1 truncate">
|
||||
<A
|
||||
href={`/organizations/${props.organizationId}/documents/${data.row.original.id}`}
|
||||
href={`/organizations/${data.row.original.organizationId}/documents/${data.row.original.id}`}
|
||||
class="font-bold truncate block hover:underline"
|
||||
>
|
||||
{data.row.original.name.split('.').shift()}
|
||||
@@ -73,29 +84,32 @@ export const DocumentsPaginatedList: Component<{ organizationId: string }> = (pr
|
||||
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Created at',
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <div class="text-muted-foreground" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: data => (
|
||||
<div class="flex items-center justify-end">
|
||||
<DocumentManagementDropdown documentId={data.row.original.id} organizationId={props.organizationId} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// header: () => (<span class="hidden sm:block">Created at</span>),
|
||||
// accessorKey: 'createdAt',
|
||||
// cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
// },
|
||||
// {
|
||||
// header: () => (<span class="block text-right">Actions</span>),
|
||||
// id: 'actions',
|
||||
// cell: data => (
|
||||
// <div class="flex items-center justify-end">
|
||||
// <DocumentManagementDropdown documentId={data.row.original.id} organizationId={data.row.original.organizationId} />
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
|
||||
...(props.extraColumns ?? []),
|
||||
],
|
||||
get rowCount() {
|
||||
return query.data?.documentsCount;
|
||||
return props.documentsCount;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: props.setPagination,
|
||||
state: {
|
||||
get pagination() {
|
||||
return getPagination();
|
||||
return props.getPagination();
|
||||
},
|
||||
},
|
||||
manualPagination: true,
|
||||
@@ -105,16 +119,10 @@ export const DocumentsPaginatedList: Component<{ organizationId: string }> = (pr
|
||||
return (
|
||||
<div>
|
||||
<Switch>
|
||||
<Match when={query.data?.documentsCount === 0}>
|
||||
<Match when={props.documentsCount === 0}>
|
||||
<p>No documents found</p>
|
||||
</Match>
|
||||
<Match when={query.isError}>
|
||||
<p>
|
||||
Error:
|
||||
{query.error?.message}
|
||||
</p>
|
||||
</Match>
|
||||
<Match when={query.isSuccess}>
|
||||
<Match when={props.documentsCount > 0}>
|
||||
<Table>
|
||||
|
||||
<TableHeader>
|
||||
@@ -55,6 +55,37 @@ export async function fetchOrganizationDocuments({
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOrganizationDeletedDocuments({
|
||||
organizationId,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
organizationId: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const {
|
||||
documents,
|
||||
documentsCount,
|
||||
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/deleted`,
|
||||
query: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
documentsCount,
|
||||
documents: documents.map(document => ({
|
||||
...document,
|
||||
createdAt: new Date(document.createdAt),
|
||||
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
@@ -68,6 +99,19 @@ export async function deleteDocument({
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
}) {
|
||||
await apiClient({
|
||||
method: 'POST',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}/restore`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
|
||||
@@ -6,4 +6,5 @@ export type Document = {
|
||||
originalSize: number;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Document } from '../documents.types';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
import { DocumentsPaginatedList } from '../components/documents-list.component';
|
||||
import { fetchOrganizationDeletedDocuments, restoreDocument } from '../documents.services';
|
||||
|
||||
export const DeletedDocumentsPage: Component = () => {
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
const params = useParams();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
||||
queryFn: () => fetchOrganizationDeletedDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const restore = async ({ document }: { document: Document }) => {
|
||||
await restoreDocument({
|
||||
documentId: document.id,
|
||||
organizationId: document.organizationId,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', document.organizationId, 'documents'] });
|
||||
|
||||
createToast({ type: 'success', message: 'Document restored' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32">
|
||||
<h1 class="text-2xl font-bold">Deleted documents</h1>
|
||||
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<AlertDescription>
|
||||
All deleted documents are stored in the trash bin for 30 days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Suspense>
|
||||
<Show when={query.data?.documents.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center gap-2 pt-24 mx-auto max-w-md text-center">
|
||||
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
|
||||
<div class="text-xl font-medium">No deleted documents</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for 30 days.
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={query.data && query.data?.documents.length > 0}>
|
||||
<DocumentsPaginatedList
|
||||
documents={query.data?.documents ?? []}
|
||||
documentsCount={query.data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
{
|
||||
id: 'deletion',
|
||||
cell: data => (
|
||||
<div class="text-muted-foreground hidden sm:block">
|
||||
Deleted
|
||||
{' '}
|
||||
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: data => (
|
||||
<div class="flex items-center justify-end">
|
||||
<Button variant="outline" size="sm" class="gap-2" onClick={() => restore({ document: data.row.original })}>
|
||||
<div class="i-tabler-refresh size-4" />
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { createQueries } from '@tanstack/solid-query';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Suspense } from 'solid-js';
|
||||
import { DocumentManagementDropdown } from '../components/document-management-dropdown.component';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
import { DocumentsPaginatedList } from '../components/documents-paginated-list.component';
|
||||
import { DocumentsPaginatedList } from '../components/documents-list.component';
|
||||
import { getDocumentIcon } from '../document.models';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
|
||||
@@ -64,57 +64,64 @@ const DocumentCard: Component<{ document: Document; organizationId?: string }> =
|
||||
|
||||
export const DocumentsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', { pageIndex: 0, pageSize: 100 }],
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
...getPagination(),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
</h2>
|
||||
|
||||
{query[0].data?.documents.length === 0
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
</p>
|
||||
<DocumentUploadArea />
|
||||
|
||||
<DocumentUploadArea />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
</h2>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{query[0].data?.documents.slice(0, 5).map(document => (
|
||||
<DocumentCard document={document} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
</h2>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{query[0].data?.documents.map(document => (
|
||||
<DocumentCard document={document} />
|
||||
))}
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold mt-8 mb-2">
|
||||
All documents
|
||||
</h2>
|
||||
|
||||
<h2 class="text-lg font-semibold mt-8 mb-2">
|
||||
All documents
|
||||
</h2>
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
/>
|
||||
</>
|
||||
|
||||
<DocumentsPaginatedList organizationId={params.organizationId} />
|
||||
</>
|
||||
|
||||
)}
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
export const ComingSoonPage: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center gap-2 pt-24">
|
||||
<div class="i-tabler-alarm text-primary size-12" />
|
||||
<div class="text-xl font-medium">Coming Soon</div>
|
||||
<div class="text-sm text-muted-foreground">This feature is coming soon, please check back later.</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export const NotFoundPage: Component = () => {
|
||||
return (
|
||||
<div class="h-screen flex flex-col items-center justify-center p-6">
|
||||
|
||||
<div class="flex items-center flex-row sm:gap-24">
|
||||
<div class="max-w-350px">
|
||||
<h1 class="text-xl mr-4 py-2">404 - Not Found</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Sorry, the page you are looking for does seem to exist. Please check the URL and try again.
|
||||
</p>
|
||||
<Button as={A} href="/" class="mt-4" variant="default">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Go back to home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block light:text-muted-foreground">
|
||||
<div class="i-tabler-file-shredder text-200px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export const alertVariants = cva(
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
primary: 'bg-background text-foreground border-primary',
|
||||
muted: 'bg-muted text-muted-foreground border-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -21,19 +21,35 @@ const OrganizationLayoutSideNav: Component = () => {
|
||||
|
||||
const getMainMenuItems = () => [
|
||||
{
|
||||
label: 'Documents',
|
||||
icon: 'i-tabler-file-text',
|
||||
label: 'Home',
|
||||
icon: 'i-tabler-home',
|
||||
href: `/organizations/${params.organizationId}`,
|
||||
},
|
||||
{
|
||||
label: 'Deleted documents',
|
||||
icon: 'i-tabler-trash',
|
||||
href: `/organizations/${params.organizationId}/deleted`,
|
||||
label: 'Documents',
|
||||
icon: 'i-tabler-file-text',
|
||||
href: `/organizations/${params.organizationId}/documents`,
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Tags',
|
||||
icon: 'i-tabler-tag',
|
||||
href: `/organizations/${params.organizationId}/tags`,
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
icon: 'i-tabler-link',
|
||||
href: `/organizations/${params.organizationId}/integrations`,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
const getFooterMenuItems = () => [
|
||||
{
|
||||
label: 'Deleted documents',
|
||||
icon: 'i-tabler-trash',
|
||||
href: `/organizations/${params.organizationId}/deleted`,
|
||||
},
|
||||
{
|
||||
label: 'Organization settings',
|
||||
icon: 'i-tabler-settings',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useThemeStore } from '@/modules/theme/theme.store';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { type Component, type ParentComponent, Show, Suspense } from 'solid-js';
|
||||
import { type Component, type ComponentProps, type JSX, type ParentComponent, Suspense } from 'solid-js';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../components/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
|
||||
@@ -21,29 +21,18 @@ type MenuItem = {
|
||||
icon: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
badge?: JSX.Element;
|
||||
};
|
||||
|
||||
const MenuItemButton: Component<MenuItem> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.onClick}>
|
||||
<Button class="block" onClick={props.onClick} variant="ghost">
|
||||
<div class="flex items-center gap-2 dark:text-muted-foreground truncate">
|
||||
<div class={cn(props.icon, 'size-5')}></div>
|
||||
<div>{props.label}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.onClick}>
|
||||
<Button class="block dark:text-muted-foreground" as={A} href={props.href!} variant="ghost" activeClass="bg-accent/50! text-accent-foreground! truncate" end>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={cn(props.icon, 'size-5')}></div>
|
||||
<div class="truncate">{props.label}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Show>
|
||||
</>
|
||||
<Button class="block" variant="ghost" {...(props.onClick ? { onClick: props.onClick } : { as: A, href: props.href, activeClass: 'bg-accent/50! text-accent-foreground! truncate', end: true } as ComponentProps<typeof Button>)}>
|
||||
<div class="flex items-center gap-2 dark:text-muted-foreground truncate">
|
||||
<div class={cn(props.icon, 'size-5')}></div>
|
||||
<div>{props.label}</div>
|
||||
{props.badge && <div class="ml-auto">{props.badge}</div>}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Navigate, type RouteDefinition, useParams } from '@solidjs/router';
|
||||
import { Navigate, type RouteDefinition, useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { createProtectedPage } from './modules/auth/middleware/protected-page.middleware';
|
||||
@@ -6,6 +6,7 @@ import { ConfirmPage } from './modules/auth/pages/confirm.page';
|
||||
import { GenericAuthPage } from './modules/auth/pages/generic-auth.page';
|
||||
import { MagicLinkSentPage } from './modules/auth/pages/magic-link-sent.page';
|
||||
import { PendingMagicLinkPage } from './modules/auth/pages/verify-magic-link.page';
|
||||
import { DeletedDocumentsPage } from './modules/documents/pages/deleted-documents.page';
|
||||
import { DocumentPage } from './modules/documents/pages/document.page';
|
||||
import { DocumentsPage } from './modules/documents/pages/documents.page';
|
||||
import { fetchOrganizations } from './modules/organizations/organizations.services';
|
||||
@@ -13,7 +14,8 @@ import { CreateFirstOrganizationPage } from './modules/organizations/pages/creat
|
||||
import { CreateOrganizationPage } from './modules/organizations/pages/create-organization.page';
|
||||
import { OrganizationsSettingsPage } from './modules/organizations/pages/organizations-settings.page';
|
||||
import { OrganizationsPage } from './modules/organizations/pages/organizations.page';
|
||||
import { Button } from './modules/ui/components/button';
|
||||
import { ComingSoonPage } from './modules/shared/pages/coming-soon.page';
|
||||
import { NotFoundPage } from './modules/shared/pages/not-found.page';
|
||||
import { OrganizationLayout } from './modules/ui/layouts/organization.layout';
|
||||
import { CurrentUserProvider, useCurrentUser } from './modules/users/composables/useCurrentUser';
|
||||
import { UserSettingsPage } from './modules/users/pages/user-settings.page';
|
||||
@@ -84,6 +86,10 @@ export const routes: RouteDefinition[] = [
|
||||
path: '/',
|
||||
component: DocumentsPage,
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
component: DocumentsPage,
|
||||
},
|
||||
{
|
||||
path: '/documents/:documentId',
|
||||
component: DocumentPage,
|
||||
@@ -92,6 +98,18 @@ export const routes: RouteDefinition[] = [
|
||||
path: '/settings',
|
||||
component: OrganizationsSettingsPage,
|
||||
},
|
||||
{
|
||||
path: '/deleted',
|
||||
component: DeletedDocumentsPage,
|
||||
},
|
||||
{
|
||||
path: '/tags',
|
||||
component: ComingSoonPage,
|
||||
},
|
||||
{
|
||||
path: '/integrations',
|
||||
component: ComingSoonPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -132,26 +150,6 @@ export const routes: RouteDefinition[] = [
|
||||
},
|
||||
{
|
||||
path: '*404',
|
||||
component: () => (
|
||||
<div class="h-screen flex flex-col items-center justify-center p-6">
|
||||
|
||||
<div class="flex items-center flex-row sm:gap-24">
|
||||
<div class="max-w-350px">
|
||||
<h1 class="text-xl mr-4 py-2">404 - Not Found</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Sorry, the page you are looking for does seem to exist. Please check the URL and try again.
|
||||
</p>
|
||||
<Button as={A} href="/" class="mt-4" variant="default">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Go back to home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block light:text-muted-foreground">
|
||||
<div class="i-tabler-file-shredder text-200px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
component: NotFoundPage,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,3 +5,9 @@ export const createDocumentNotFoundError = createErrorFactory({
|
||||
code: 'document.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
export const createDocumentIsNotDeletedError = createErrorFactory({
|
||||
message: 'Document is not deleted, cannot restore.',
|
||||
code: 'document.not_deleted',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
@@ -12,10 +12,13 @@ export function createDocumentsRepository({ db }: { db: Database }) {
|
||||
{
|
||||
saveOrganizationDocument,
|
||||
getOrganizationDocuments,
|
||||
getOrganizationDeletedDocuments,
|
||||
getDocumentById,
|
||||
softDeleteDocument,
|
||||
getOrganizationDocumentsCount,
|
||||
getOrganizationDeletedDocumentsCount,
|
||||
searchOrganizationDocuments,
|
||||
restoreDocument,
|
||||
},
|
||||
{ db },
|
||||
);
|
||||
@@ -30,7 +33,7 @@ async function saveOrganizationDocument({ db, ...documentToInsert }: { db: Datab
|
||||
async function getOrganizationDocumentsCount({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [{ documentsCount }] = await db
|
||||
.select({
|
||||
documentsCount: count(),
|
||||
documentsCount: count(documentsTable.id),
|
||||
})
|
||||
.from(documentsTable)
|
||||
.where(
|
||||
@@ -43,6 +46,22 @@ async function getOrganizationDocumentsCount({ organizationId, db }: { organizat
|
||||
return { documentsCount };
|
||||
}
|
||||
|
||||
async function getOrganizationDeletedDocumentsCount({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [{ documentsCount }] = await db
|
||||
.select({
|
||||
documentsCount: count(documentsTable.id),
|
||||
})
|
||||
.from(documentsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(documentsTable.organizationId, organizationId),
|
||||
eq(documentsTable.isDeleted, true),
|
||||
),
|
||||
);
|
||||
|
||||
return { documentsCount };
|
||||
}
|
||||
|
||||
async function getOrganizationDocuments({ organizationId, pageIndex, pageSize, db }: { organizationId: string; pageIndex: number; pageSize: number; db: Database }) {
|
||||
const query = db
|
||||
.select()
|
||||
@@ -68,6 +87,31 @@ async function getOrganizationDocuments({ organizationId, pageIndex, pageSize, d
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrganizationDeletedDocuments({ organizationId, pageIndex, pageSize, db }: { organizationId: string; pageIndex: number; pageSize: number; db: Database }) {
|
||||
const query = db
|
||||
.select()
|
||||
.from(documentsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(documentsTable.organizationId, organizationId),
|
||||
eq(documentsTable.isDeleted, true),
|
||||
),
|
||||
);
|
||||
|
||||
const documents = await withPagination(
|
||||
query.$dynamic(),
|
||||
{
|
||||
orderByColumn: desc(documentsTable.deletedAt),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDocumentById({ documentId, db }: { documentId: string; db: Database }) {
|
||||
const [document] = await db
|
||||
.select()
|
||||
@@ -90,6 +134,17 @@ async function softDeleteDocument({ documentId, userId, db, now = new Date() }:
|
||||
.where(eq(documentsTable.id, documentId));
|
||||
}
|
||||
|
||||
async function restoreDocument({ documentId, db }: { documentId: string; db: Database }) {
|
||||
await db
|
||||
.update(documentsTable)
|
||||
.set({
|
||||
isDeleted: false,
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
})
|
||||
.where(eq(documentsTable.id, documentId));
|
||||
}
|
||||
|
||||
async function searchOrganizationDocuments({ organizationId, searchQuery, pageIndex, pageSize, db }: { organizationId: string; searchQuery: string; pageIndex: number; pageSize: number; db: Database }) {
|
||||
// TODO: extract this logic to a tested function
|
||||
// when searchquery is a single word, we append a wildcard to it to make it a prefix search
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createOrganizationsRepository } from '../organizations/organizations.re
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { validateFormData, validateParams, validateQuery } from '../shared/validation/validation';
|
||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
import { createDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
|
||||
import { createDocumentStorageService } from './storage/documents.storage.services';
|
||||
@@ -17,6 +18,8 @@ export function registerDocumentsPrivateRoutes({ app }: { app: ServerInstance })
|
||||
setupCreateDocumentRoute({ app });
|
||||
setupGetDocumentsRoute({ app });
|
||||
setupSearchDocumentsRoute({ app });
|
||||
setupRestoreDocumentRoute({ app });
|
||||
setupGetDeletedDocumentsRoute({ app });
|
||||
setupGetDocumentRoute({ app });
|
||||
setupDeleteDocumentRoute({ app });
|
||||
setupGetDocumentFileRoute({ app });
|
||||
@@ -132,6 +135,46 @@ function setupGetDocumentsRoute({ app }: { app: ServerInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
function setupGetDeletedDocumentsRoute({ app }: { app: ServerInstance }) {
|
||||
app.get(
|
||||
'/api/organizations/:organizationId/documents/deleted',
|
||||
validateParams(z.object({
|
||||
organizationId: z.string().regex(organizationIdRegex),
|
||||
})),
|
||||
validateQuery(
|
||||
z.object({
|
||||
pageIndex: z.coerce.number().min(0).int().optional().default(0),
|
||||
pageSize: z.coerce.number().min(1).max(100).int().optional().default(100),
|
||||
}),
|
||||
),
|
||||
async (context) => {
|
||||
const { userId } = getAuthUserId({ context });
|
||||
const { db } = getDb({ context });
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
const { pageIndex, pageSize } = context.req.valid('query');
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const [
|
||||
{ documents },
|
||||
{ documentsCount },
|
||||
] = await Promise.all([
|
||||
documentsRepository.getOrganizationDeletedDocuments({ organizationId, pageIndex, pageSize }),
|
||||
documentsRepository.getOrganizationDeletedDocumentsCount({ organizationId }),
|
||||
]);
|
||||
|
||||
return context.json({
|
||||
documents,
|
||||
documentsCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setupGetDocumentRoute({ app }: { app: ServerInstance }) {
|
||||
app.get(
|
||||
'/api/organizations/:organizationId/documents/:documentId',
|
||||
@@ -187,6 +230,37 @@ function setupDeleteDocumentRoute({ app }: { app: ServerInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
function setupRestoreDocumentRoute({ app }: { app: ServerInstance }) {
|
||||
app.post(
|
||||
'/api/organizations/:organizationId/documents/:documentId/restore',
|
||||
validateParams(z.object({
|
||||
organizationId: z.string().regex(organizationIdRegex),
|
||||
documentId: z.string(),
|
||||
})),
|
||||
async (context) => {
|
||||
const { userId } = getAuthUserId({ context });
|
||||
const { db } = getDb({ context });
|
||||
|
||||
const { organizationId, documentId } = context.req.valid('param');
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { document } = await getDocumentOrThrow({ documentId, documentsRepository });
|
||||
|
||||
if (!document.isDeleted) {
|
||||
throw createDocumentIsNotDeletedError();
|
||||
}
|
||||
|
||||
await documentsRepository.restoreDocument({ documentId });
|
||||
|
||||
return context.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setupGetDocumentFileRoute({ app }: { app: ServerInstance }) {
|
||||
app.get(
|
||||
'/api/organizations/:organizationId/documents/:documentId/file',
|
||||
|
||||
Reference in New Issue
Block a user