mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 22:15:01 +10:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 |
@@ -1,67 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import type { MonthlyStats } from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
|
||||
|
||||
export type AnalyticsChartsProps = {
|
||||
monthlyData?: MonthlyStats;
|
||||
showActiveUsers?: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsCharts = ({ monthlyData, showActiveUsers = false }: AnalyticsChartsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
if (!monthlyData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure all data has cume_count for chart compatibility
|
||||
const formatDataForChart = (data: MonthlyStats) => {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
count: item.count,
|
||||
cume_count: item.cume_count || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{showActiveUsers && (
|
||||
<>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Active Users (created document)`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
/>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative Active Users`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
cummulative
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showActiveUsers && (
|
||||
<>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Documents Created`)}
|
||||
data={formatDataForChart(monthlyData)}
|
||||
/>
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Documents Completed`)}
|
||||
data={formatDataForChart(
|
||||
monthlyData.map((d) => ({ ...d, count: d.signed_count || 0 })),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
export type DateFilterPeriod = 'all' | 'year' | 'month' | 'week' | 'day';
|
||||
|
||||
export type AnalyticsDateFilterProps = {
|
||||
value: DateFilterPeriod;
|
||||
onChange: (value: DateFilterPeriod) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsDateFilter = ({ value, onChange, className }: AnalyticsDateFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{ value: 'all' as const, label: _(msg`All Time`) },
|
||||
{ value: 'year' as const, label: _(msg`This Year`) },
|
||||
{ value: 'month' as const, label: _(msg`This Month`) },
|
||||
{ value: 'week' as const, label: _(msg`This Week`) },
|
||||
{ value: 'day' as const, label: _(msg`Today`) },
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder={_(msg`Select period`)}>{selectedOption?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getDateRangeFromPeriod = (period: DateFilterPeriod) => {
|
||||
const now = DateTime.now();
|
||||
|
||||
switch (period) {
|
||||
case 'day':
|
||||
return {
|
||||
dateFrom: now.startOf('day').toJSDate(),
|
||||
dateTo: now.endOf('day').toJSDate(),
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
dateFrom: now.startOf('week').toJSDate(),
|
||||
dateTo: now.endOf('week').toJSDate(),
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
dateFrom: now.startOf('month').toJSDate(),
|
||||
dateTo: now.endOf('month').toJSDate(),
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
dateFrom: now.startOf('year').toJSDate(),
|
||||
dateTo: now.endOf('year').toJSDate(),
|
||||
};
|
||||
case 'all':
|
||||
default:
|
||||
return {
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
UserSquare2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { DocumentStats, RecipientStats } from '@documenso/lib/server-only/analytics';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { CardMetric } from '~/components/general/metric-card';
|
||||
|
||||
export type AnalyticsMetricsProps = {
|
||||
docStats: DocumentStats;
|
||||
recipientStats: RecipientStats;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsMetrics = ({
|
||||
docStats,
|
||||
recipientStats,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: AnalyticsMetricsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Overview Metrics */}
|
||||
<div
|
||||
className={cn('grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4', {
|
||||
'pointer-events-none opacity-50': isLoading,
|
||||
})}
|
||||
>
|
||||
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Document Metrics Section */}
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Document metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||
<CardMetric icon={FileClock} title={_(msg`Pending Documents`)} value={docStats.PENDING} />
|
||||
<CardMetric
|
||||
icon={FileCheck}
|
||||
title={_(msg`Completed Documents`)}
|
||||
value={docStats.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients Metrics Section */}
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">
|
||||
<Trans>Recipients metrics</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CardMetric
|
||||
icon={UserSquare2}
|
||||
title={_(msg`Total Recipients`)}
|
||||
value={recipientStats.TOTAL_RECIPIENTS}
|
||||
/>
|
||||
<CardMetric icon={Mail} title={_(msg`Documents Sent`)} value={recipientStats.SENT} />
|
||||
<CardMetric
|
||||
icon={MailOpen}
|
||||
title={_(msg`Documents Viewed`)}
|
||||
value={recipientStats.OPENED}
|
||||
/>
|
||||
<CardMetric
|
||||
icon={PenTool}
|
||||
title={_(msg`Signatures Collected`)}
|
||||
value={recipientStats.SIGNED}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigation } from 'react-router';
|
||||
|
||||
import type {
|
||||
DocumentStats,
|
||||
MonthlyStats,
|
||||
RecipientStats,
|
||||
} from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import { AnalyticsCharts } from './analytics-charts';
|
||||
import { AnalyticsDateFilter, type DateFilterPeriod } from './analytics-date-filter';
|
||||
import { AnalyticsMetrics } from './analytics-metrics';
|
||||
|
||||
export type AnalyticsPageData = {
|
||||
docStats: DocumentStats;
|
||||
recipientStats: RecipientStats;
|
||||
monthlyData?: MonthlyStats;
|
||||
period: DateFilterPeriod;
|
||||
};
|
||||
|
||||
export type AnalyticsPageProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
data: AnalyticsPageData;
|
||||
showCharts?: boolean;
|
||||
onPeriodChange: (period: DateFilterPeriod) => void;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsPage = ({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
showCharts = true,
|
||||
onPeriodChange,
|
||||
containerClassName = 'mx-auto w-full max-w-screen-xl px-4 md:px-8',
|
||||
}: AnalyticsPageProps) => {
|
||||
const navigation = useNavigation();
|
||||
const { docStats, recipientStats, monthlyData, period } = data;
|
||||
const isLoading = navigation.state === 'loading';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>{title}</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
<Trans>{subtitle}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<AnalyticsDateFilter value={period} onChange={onPeriodChange} />
|
||||
</div>
|
||||
|
||||
<AnalyticsMetrics
|
||||
docStats={docStats}
|
||||
recipientStats={recipientStats}
|
||||
isLoading={isLoading}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
{showCharts && (
|
||||
<AnalyticsCharts
|
||||
monthlyData={monthlyData}
|
||||
showActiveUsers={!!monthlyData?.[0]?.cume_count}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
folderId: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
@@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const isTeamContext = !!team;
|
||||
|
||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||
defaultValues: {
|
||||
@@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTeamContext && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||
};
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
@@ -53,6 +47,15 @@ export const FolderCard = ({
|
||||
return `${rootPath}/f/${folder.id}`;
|
||||
};
|
||||
|
||||
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
|
||||
await updateFolderMutation({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
pinned,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
@@ -112,9 +115,7 @@ export const FolderCard = ({
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -34,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||
type,
|
||||
parentId,
|
||||
@@ -155,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -180,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -97,12 +97,6 @@ export const MenuSwitcher = () => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/analytics">
|
||||
<Trans>Personal analytics</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground px-4 py-2"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
|
||||
@@ -307,18 +307,11 @@ export const OrgMenuSwitcher = () => {
|
||||
)}
|
||||
|
||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<>
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/analytics`}>
|
||||
<Trans>Team analytics</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
@@ -129,19 +128,6 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/analytics">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BarChart3Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Analytics</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BracesIcon,
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
@@ -129,19 +128,6 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/analytics">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<BarChart3Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Analytics</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { DateFilterPeriod } from '~/components/analytics/analytics-date-filter';
|
||||
|
||||
export const useAnalyticsFilter = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const period = (searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const handlePeriodChange = (newPeriod: DateFilterPeriod) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
} else {
|
||||
params.set('period', newPeriod);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
return {
|
||||
period,
|
||||
handlePeriodChange,
|
||||
};
|
||||
};
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getUserDocumentStats, getUserRecipientsStats } from '@documenso/lib/server-only/analytics';
|
||||
|
||||
import {
|
||||
type DateFilterPeriod,
|
||||
getDateRangeFromPeriod,
|
||||
} from '~/components/analytics/analytics-date-filter';
|
||||
import { AnalyticsPage } from '~/components/analytics/analytics-page';
|
||||
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Personal Analytics');
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
const url = new URL(request.url);
|
||||
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
|
||||
|
||||
const [docStats, recipientStats] = await Promise.all([
|
||||
getUserDocumentStats({ userId: session.user.id, dateFrom, dateTo }),
|
||||
getUserRecipientsStats({ userId: session.user.id, dateFrom, dateTo }),
|
||||
]);
|
||||
|
||||
return {
|
||||
docStats,
|
||||
recipientStats,
|
||||
period,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PersonalAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { handlePeriodChange } = useAnalyticsFilter();
|
||||
const { docStats, recipientStats, period } = loaderData;
|
||||
|
||||
return (
|
||||
<AnalyticsPage
|
||||
title="Personal Analytics"
|
||||
subtitle="Your personal document signing analytics and insights"
|
||||
data={{
|
||||
docStats,
|
||||
recipientStats,
|
||||
period,
|
||||
}}
|
||||
showCharts={false}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
containerClassName=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
getTeamDocumentStats,
|
||||
getTeamMonthlyActiveUsers,
|
||||
getTeamRecipientsStats,
|
||||
} from '@documenso/lib/server-only/analytics';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
|
||||
import {
|
||||
type DateFilterPeriod,
|
||||
getDateRangeFromPeriod,
|
||||
} from '~/components/analytics/analytics-date-filter';
|
||||
import { AnalyticsPage } from '~/components/analytics/analytics-page';
|
||||
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/analytics';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Team Analytics');
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await getSession(request);
|
||||
const url = new URL(request.url);
|
||||
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
|
||||
|
||||
const team = await getTeamByUrl({
|
||||
userId: session.user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
throw redirect(`/t/${params.teamUrl}`);
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
|
||||
|
||||
const [docStats, recipientStats, monthlyActiveUsers] = await Promise.all([
|
||||
getTeamDocumentStats({ teamId: team.id, dateFrom, dateTo }),
|
||||
getTeamRecipientsStats({ teamId: team.id, dateFrom, dateTo }),
|
||||
getTeamMonthlyActiveUsers(team.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
docStats,
|
||||
recipientStats,
|
||||
monthlyActiveUsers,
|
||||
period,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load team analytics:', error);
|
||||
|
||||
// If it's a redirect, re-throw it
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Response('Failed to load team analytics data', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
|
||||
const team = useCurrentTeam();
|
||||
const { handlePeriodChange } = useAnalyticsFilter();
|
||||
|
||||
const { docStats, recipientStats, monthlyActiveUsers, period } = loaderData;
|
||||
|
||||
return (
|
||||
<AnalyticsPage
|
||||
title="Team Analytics"
|
||||
subtitle={`Analytics and insights for ${team.name}`}
|
||||
data={{
|
||||
docStats,
|
||||
recipientStats,
|
||||
monthlyData: monthlyActiveUsers,
|
||||
period,
|
||||
}}
|
||||
showCharts={true}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,6 @@ export default function DocumentsFoldersPage() {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
@@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -147,8 +142,6 @@ export default function DocumentsFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -42,9 +42,6 @@ export default function TemplatesFoldersPage() {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
@@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -147,8 +142,6 @@ export default function TemplatesFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZBaseEmbedAuthoringSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
externalId: z.string().optional(),
|
||||
features: z
|
||||
.object({
|
||||
|
||||
@@ -103,5 +103,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.10"
|
||||
"version": "1.13.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from 'hono/cors';
|
||||
import { requestId } from 'hono/request-id';
|
||||
import type { RequestIdVariables } from 'hono/request-id';
|
||||
import type { Logger } from 'pino';
|
||||
@@ -83,12 +84,14 @@ app.route('/api/auth', auth);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||
|
||||
export default app;
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@documenso/api": "*",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -326,11 +327,6 @@ test.describe('Document API V2', () => {
|
||||
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
||||
});
|
||||
|
||||
const asdf = await res.json();
|
||||
console.log({
|
||||
asdf,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
@@ -407,11 +403,6 @@ test.describe('Document API V2', () => {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const asdf = await res.json();
|
||||
console.log({
|
||||
asdf,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
@@ -2715,4 +2706,154 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder list endpoint', () => {
|
||||
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const { data } = await res.json();
|
||||
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
|
||||
expect(data.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder list endpoint', async ({ request }) => {
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
// Other team folders should not be visible.
|
||||
await seedBlankFolder(userA, teamB.id);
|
||||
await seedBlankFolder(userA, teamB.id);
|
||||
|
||||
// Other team and user folders should not be visible.
|
||||
await seedBlankFolder(userB, teamB.id);
|
||||
await seedBlankFolder(userB, teamB.id);
|
||||
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const { data } = await res.json();
|
||||
|
||||
expect(data.length).toBe(2);
|
||||
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder create endpoint', () => {
|
||||
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
|
||||
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
parentId: unauthorizedFolder.id,
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder create endpoint', async ({ request }) => {
|
||||
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
parentId: authorizedFolder.id,
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(noParentRes.ok()).toBeTruthy();
|
||||
expect(noParentRes.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder update endpoint', () => {
|
||||
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: 'Updated Folder Name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder update endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: 'Updated Folder Name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder delete endpoint', () => {
|
||||
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { DocumentStatus, Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
DocumentStats,
|
||||
TeamDocumentStatsFilters,
|
||||
UserDocumentStatsFilters,
|
||||
} from './types';
|
||||
|
||||
export const getDocumentStats = async (filters: AnalyticsFilters): Promise<DocumentStats> => {
|
||||
const { dateFrom, dateTo, ...entityFilter } = filters;
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
...entityFilter,
|
||||
deletedAt: null,
|
||||
...(dateFrom || dateTo
|
||||
? {
|
||||
createdAt: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const counts = await prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
where,
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stats: DocumentStats = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
counts.forEach((stat: { status: DocumentStatus; _count: { _all: number } }) => {
|
||||
stats[stat.status as DocumentStatus] = stat._count._all;
|
||||
stats.ALL += stat._count._all;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
// Legacy wrapper functions for backwards compatibility
|
||||
export const getTeamDocumentStats = async (filters: TeamDocumentStatsFilters) => {
|
||||
return getDocumentStats({
|
||||
teamId: filters.teamId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserDocumentStats = async (filters: UserDocumentStatsFilters) => {
|
||||
return getDocumentStats({
|
||||
userId: filters.userId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { MonthlyStats } from './types';
|
||||
|
||||
type MonthlyDocumentGrowthQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
signed_count: bigint;
|
||||
}>;
|
||||
|
||||
type MonthlyActiveUsersQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
cume_count: bigint;
|
||||
}>;
|
||||
|
||||
export const getTeamMonthlyDocumentGrowth = async (teamId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."id") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."teamId" = ${teamId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUserMonthlyDocumentGrowth = async (userId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."id") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."userId" = ${userId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTeamMonthlyActiveUsers = async (teamId: number): Promise<MonthlyStats> => {
|
||||
const result = await prisma.$queryRaw<MonthlyActiveUsersQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."userId") as "count",
|
||||
SUM(COUNT(DISTINCT "Document"."userId")) OVER (ORDER BY DATE_TRUNC('month', "Document"."createdAt")) as "cume_count"
|
||||
FROM "Document"
|
||||
WHERE "Document"."teamId" = ${teamId}
|
||||
AND "Document"."deletedAt" IS NULL
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { type Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type {
|
||||
AnalyticsFilters,
|
||||
RecipientStats,
|
||||
TeamRecipientsStatsFilters,
|
||||
UserRecipientsStatsFilters,
|
||||
} from './types';
|
||||
|
||||
export const getRecipientStats = async (filters: AnalyticsFilters): Promise<RecipientStats> => {
|
||||
const { dateFrom, dateTo, ...entityFilter } = filters;
|
||||
|
||||
const where: Prisma.RecipientWhereInput = {
|
||||
envelope: {
|
||||
...entityFilter,
|
||||
deletedAt: null,
|
||||
...(dateFrom || dateTo
|
||||
? {
|
||||
createdAt: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const results = await prisma.recipient.groupBy({
|
||||
by: ['readStatus', 'signingStatus', 'sendStatus'],
|
||||
where,
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const stats: RecipientStats = {
|
||||
TOTAL_RECIPIENTS: 0,
|
||||
[ReadStatus.OPENED]: 0,
|
||||
[ReadStatus.NOT_OPENED]: 0,
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SigningStatus.REJECTED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
// Legacy wrapper functions for backwards compatibility
|
||||
export const getTeamRecipientsStats = async (filters: TeamRecipientsStatsFilters) => {
|
||||
return getRecipientStats({
|
||||
teamId: filters.teamId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserRecipientsStats = async (filters: UserRecipientsStatsFilters) => {
|
||||
return getRecipientStats({
|
||||
userId: filters.userId,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './types';
|
||||
export * from './get-document-stats';
|
||||
export * from './get-recipient-stats';
|
||||
export * from './get-monthly-stats';
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type EntityFilter = { teamId: number } | { userId: number };
|
||||
|
||||
export type AnalyticsDateFilter = {
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type AnalyticsFilters = EntityFilter & AnalyticsDateFilter;
|
||||
|
||||
export type DocumentStats = Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number>;
|
||||
|
||||
export type RecipientStats = {
|
||||
TOTAL_RECIPIENTS: number;
|
||||
[ReadStatus.OPENED]: number;
|
||||
[ReadStatus.NOT_OPENED]: number;
|
||||
[SigningStatus.SIGNED]: number;
|
||||
[SigningStatus.NOT_SIGNED]: number;
|
||||
[SigningStatus.REJECTED]: number;
|
||||
[SendStatus.SENT]: number;
|
||||
[SendStatus.NOT_SENT]: number;
|
||||
};
|
||||
|
||||
export type MonthlyStats = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
signed_count?: number;
|
||||
cume_count?: number;
|
||||
}>;
|
||||
|
||||
export type UserMonthlyDocumentGrowth = MonthlyStats;
|
||||
|
||||
export type TeamMonthlyDocumentGrowth = MonthlyStats;
|
||||
|
||||
export type TeamMonthlyActiveUsers = MonthlyStats;
|
||||
|
||||
// Legacy type exports for backwards compatibility
|
||||
export type TeamDocumentStatsFilters = {
|
||||
teamId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type UserDocumentStatsFilters = {
|
||||
userId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type TeamRecipientsStatsFilters = {
|
||||
teamId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
|
||||
export type UserRecipientsStatsFilters = {
|
||||
userId: number;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
};
|
||||
@@ -81,11 +81,15 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const completedDocumentEmailAttachments = await Promise.all(
|
||||
envelope.envelopeItems.map(async (document) => {
|
||||
const file = await getFileServerSide(document.documentData);
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
const file = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
// Use the envelope title for version 1, and the envelope item title for version 2.
|
||||
const fileNameToUse =
|
||||
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
|
||||
|
||||
return {
|
||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
|
||||
content: Buffer.from(file),
|
||||
contentType: 'application/pdf',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export interface CreateFolderOptions {
|
||||
@@ -22,6 +24,27 @@ export const createFolder = async ({
|
||||
// This indirectly verifies whether the user has access to the team.
|
||||
const settings = await getTeamSettings({ userId, teamId });
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentFolder.type !== type) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Parent folder type does not match the folder type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
@@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
|
||||
return await prisma.folder.delete({
|
||||
where: {
|
||||
id: folderId,
|
||||
id: folder.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface FindFoldersInternalOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const findFoldersInternal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
}: FindFoldersInternalOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface FindFoldersOptions {
|
||||
@@ -11,102 +13,48 @@ export interface FindFoldersOptions {
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
||||
export const findFolders = async ({
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindFoldersOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = {
|
||||
const whereClause: Prisma.FolderWhereInput = {
|
||||
parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
|
||||
@@ -1,51 +1,30 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface GetFolderByIdOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = {
|
||||
id: folderId,
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
};
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: whereClause,
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface MoveFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId?: string;
|
||||
parentId?: string | null;
|
||||
requestMetadata?: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const folder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
userId,
|
||||
teamId,
|
||||
type: folder.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into itself',
|
||||
});
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.parentId;
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into its descendant',
|
||||
});
|
||||
}
|
||||
|
||||
const currentParent = await tx.folder.findUnique({
|
||||
where: {
|
||||
id: currentParentId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentParent) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentParentId = currentParent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
return await tx.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface PinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UnpinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,28 +1,28 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface UpdateFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
folderId: string;
|
||||
name: string;
|
||||
visibility: DocumentVisibility;
|
||||
type?: TFolderType;
|
||||
data: {
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
pinned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const updateFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
name,
|
||||
visibility,
|
||||
type,
|
||||
}: UpdateFolderOptions) => {
|
||||
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
|
||||
const { parentId, name, visibility, pinned } = data;
|
||||
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
@@ -30,7 +30,9 @@ export const updateFolder = async ({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,17 +42,66 @@ export const updateFolder = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
|
||||
const effectiveVisibility =
|
||||
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
|
||||
if (parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type: folder.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into itself',
|
||||
});
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.parentId;
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into its descendant',
|
||||
});
|
||||
}
|
||||
|
||||
const currentParent = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: currentParentId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentParent) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentParentId = currentParent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
visibility: effectiveVisibility,
|
||||
visibility,
|
||||
parentId,
|
||||
pinned,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
.selectFrom('Envelope')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
||||
@@ -86,7 +86,10 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
},
|
||||
],
|
||||
},
|
||||
meta,
|
||||
meta: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings ?? undefined,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@@ -2,27 +2,26 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
|
||||
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
|
||||
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
|
||||
import { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal';
|
||||
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
|
||||
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
|
||||
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
|
||||
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
|
||||
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
|
||||
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateFolderSchema,
|
||||
ZDeleteFolderSchema,
|
||||
ZCreateFolderRequestSchema,
|
||||
ZCreateFolderResponseSchema,
|
||||
ZDeleteFolderRequestSchema,
|
||||
ZFindFoldersInternalRequestSchema,
|
||||
ZFindFoldersInternalResponseSchema,
|
||||
ZFindFoldersRequestSchema,
|
||||
ZFindFoldersResponseSchema,
|
||||
ZGenericSuccessResponse,
|
||||
ZGetFoldersResponseSchema,
|
||||
ZGetFoldersSchema,
|
||||
ZMoveFolderSchema,
|
||||
ZPinFolderSchema,
|
||||
ZSuccessResponseSchema,
|
||||
ZUnpinFolderSchema,
|
||||
ZUpdateFolderSchema,
|
||||
ZUpdateFolderRequestSchema,
|
||||
ZUpdateFolderResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export const folderRouter = router({
|
||||
@@ -43,7 +42,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFolders({
|
||||
const folders = await findFoldersInternal({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@@ -67,11 +66,47 @@ export const folderRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
findFolders: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/folder',
|
||||
summary: 'Find folders',
|
||||
description: 'Find folders based on a search criteria',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZFindFoldersRequestSchema)
|
||||
.output(ZFindFoldersResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type, page, perPage } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
parentId,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
return await findFolders({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
findFoldersInternal: authenticatedProcedure
|
||||
.input(ZFindFoldersInternalRequestSchema)
|
||||
.output(ZFindFoldersInternalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type } = input;
|
||||
@@ -83,7 +118,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFolders({
|
||||
const folders = await findFoldersInternal({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@@ -107,10 +142,20 @@ export const folderRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
createFolder: authenticatedProcedure
|
||||
.input(ZCreateFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/create',
|
||||
summary: 'Create new folder',
|
||||
description: 'Creates a new folder in your team',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZCreateFolderRequestSchema)
|
||||
.output(ZCreateFolderResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { name, parentId, type } = input;
|
||||
@@ -145,181 +190,77 @@ export const folderRouter = router({
|
||||
type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type,
|
||||
};
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
updateFolder: authenticatedProcedure
|
||||
.input(ZUpdateFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/update',
|
||||
summary: 'Update folder',
|
||||
description: 'Updates an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZUpdateFolderRequestSchema)
|
||||
.output(ZUpdateFolderResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id, name, visibility } = input;
|
||||
const { folderId, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
const result = await updateFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
name,
|
||||
visibility,
|
||||
type: currentFolder.type,
|
||||
folderId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
deleteFolder: authenticatedProcedure
|
||||
.input(ZDeleteFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/delete',
|
||||
summary: 'Delete folder',
|
||||
description: 'Deletes an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZDeleteFolderRequestSchema)
|
||||
.output(ZSuccessResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id } = input;
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
folderId,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id, parentId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
if (parentId !== null) {
|
||||
try {
|
||||
await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: parentId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await moveFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
parentId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await pinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await unpinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
|
||||
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
|
||||
/**
|
||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||
@@ -11,24 +12,23 @@ import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
*/
|
||||
export const ZSuccessResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZGenericSuccessResponse = {
|
||||
success: true,
|
||||
} satisfies z.infer<typeof ZSuccessResponseSchema>;
|
||||
|
||||
export const ZFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullable(),
|
||||
parentId: z.string().nullable(),
|
||||
pinned: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
visibility: z.nativeEnum(DocumentVisibility),
|
||||
type: ZFolderTypeSchema,
|
||||
export const ZFolderSchema = FolderSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
pinned: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
visibility: true,
|
||||
type: true,
|
||||
});
|
||||
|
||||
export type TFolder = z.infer<typeof ZFolderSchema>;
|
||||
@@ -51,40 +51,39 @@ export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({
|
||||
|
||||
export type TFolderWithSubfolders = z.infer<typeof ZFolderWithSubfoldersSchema>;
|
||||
|
||||
export const ZCreateFolderSchema = z.object({
|
||||
const ZFolderParentIdSchema = z
|
||||
.string()
|
||||
.describe(
|
||||
'The folder ID to place this folder within. Leave empty to place folder at the root level.',
|
||||
);
|
||||
|
||||
export const ZCreateFolderRequestSchema = z.object({
|
||||
name: z.string(),
|
||||
parentId: z.string().optional(),
|
||||
parentId: ZFolderParentIdSchema.optional(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
visibility: z.nativeEnum(DocumentVisibility),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
export const ZCreateFolderResponseSchema = ZFolderSchema;
|
||||
|
||||
export const ZUpdateFolderRequestSchema = z.object({
|
||||
folderId: z.string().describe('The ID of the folder to update'),
|
||||
data: z.object({
|
||||
name: z.string().optional().describe('The name of the folder'),
|
||||
parentId: ZFolderParentIdSchema.optional().nullable(),
|
||||
visibility: z
|
||||
.nativeEnum(DocumentVisibility)
|
||||
.optional()
|
||||
.describe('The visibility of the folder'),
|
||||
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TUpdateFolderSchema = z.infer<typeof ZUpdateFolderSchema>;
|
||||
export type TUpdateFolderRequestSchema = z.infer<typeof ZUpdateFolderRequestSchema>;
|
||||
|
||||
export const ZDeleteFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
export const ZUpdateFolderResponseSchema = ZFolderSchema;
|
||||
|
||||
export const ZMoveFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
parentId: z.string().nullable(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZPinFolderSchema = z.object({
|
||||
export const ZDeleteFolderRequestSchema = z.object({
|
||||
folderId: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZUnpinFolderSchema = z.object({
|
||||
folderId: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZGetFoldersSchema = z.object({
|
||||
@@ -101,11 +100,20 @@ export const ZGetFoldersResponseSchema = z.object({
|
||||
export type TGetFoldersResponse = z.infer<typeof ZGetFoldersResponseSchema>;
|
||||
|
||||
export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
parentId: z.string().optional().describe('Filter folders by the parent folder ID'),
|
||||
type: ZFolderTypeSchema.optional().describe('Filter folders by the folder type'),
|
||||
});
|
||||
|
||||
export const ZFindFoldersResponseSchema = ZFindResultResponse.extend({
|
||||
data: z.array(ZFolderSchema),
|
||||
});
|
||||
|
||||
export const ZFindFoldersInternalRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
parentId: z.string().nullable().optional(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZFindFoldersResponseSchema = z.object({
|
||||
export const ZFindFoldersInternalResponseSchema = z.object({
|
||||
data: z.array(ZFolderWithSubfoldersSchema),
|
||||
breadcrumbs: z.array(ZFolderSchema),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user