diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index 315c2022b..2b4576eb3 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react'; +import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates'; @@ -18,6 +18,7 @@ import { import { DeleteTemplateDialog } from './delete-template-dialog'; import { DuplicateTemplateDialog } from './duplicate-template-dialog'; +import { MoveTemplateDialog } from './move-template-dialog'; import { TemplateDirectLinkDialog } from './template-direct-link-dialog'; export type DataTableActionDropdownProps = { @@ -36,6 +37,7 @@ export const DataTableActionDropdown = ({ const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + const [isMoveDialogOpen, setMoveDialogOpen] = useState(false); if (!session) { return null; @@ -73,6 +75,13 @@ export const DataTableActionDropdown = ({ Direct link + {!teamId && ( + setMoveDialogOpen(true)}> + + Move to Team + + )} + setDeleteDialogOpen(true)} @@ -95,6 +104,12 @@ export const DataTableActionDropdown = ({ onOpenChange={setTemplateDirectLinkDialogOpen} /> + + void; +}; + +export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [selectedTeamId, setSelectedTeamId] = useState(null); + + const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); + const { mutateAsync: moveTemplate, isLoading } = trpc.template.moveTemplateToTeam.useMutation({ + onSuccess: () => { + router.refresh(); + toast({ + title: 'Template moved', + description: 'The template has been successfully moved to the selected team.', + duration: 5000, + }); + onOpenChange(false); + }, + onError: (error) => { + toast({ + title: 'Error', + description: error.message || 'An error occurred while moving the template.', + variant: 'destructive', + duration: 7500, + }); + }, + }); + + const onMove = async () => { + if (!selectedTeamId) { + return; + } + + await moveTemplate({ templateId, teamId: selectedTeamId }); + }; + + return ( + + + + Move Template to Team + + Select a team to move this template to. This action cannot be undone. + + + + + + + + + + + + ); +}; diff --git a/packages/lib/server-only/template/move-template-to-team.ts b/packages/lib/server-only/template/move-template-to-team.ts new file mode 100644 index 000000000..522aa5e9b --- /dev/null +++ b/packages/lib/server-only/template/move-template-to-team.ts @@ -0,0 +1,57 @@ +import { TRPCError } from '@trpc/server'; + +import { prisma } from '@documenso/prisma'; + +export type MoveTemplateToTeamOptions = { + templateId: number; + teamId: number; + userId: number; +}; + +export const moveTemplateToTeam = async ({ + templateId, + teamId, + userId, +}: MoveTemplateToTeamOptions) => { + return await prisma.$transaction(async (tx) => { + const template = await tx.template.findFirst({ + where: { + id: templateId, + userId, + teamId: null, + }, + }); + + if (!template) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Template not found or already associated with a team.', + }); + } + + const team = await tx.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this team.', + }); + } + + const updatedTemplate = await tx.template.update({ + where: { id: templateId }, + data: { teamId }, + }); + + return updatedTemplate; + }); +}; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 0736dfb34..f4a76a6f5 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -12,6 +12,7 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id'; +import { moveTemplateToTeam } from '@documenso/lib/server-only/template/move-template-to-team'; import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; @@ -28,6 +29,7 @@ import { ZDuplicateTemplateMutationSchema, ZFindTemplatesQuerySchema, ZGetTemplateWithDetailsByIdQuerySchema, + ZMoveTemplatesToTeamSchema, ZToggleTemplateDirectLinkMutationSchema, ZUpdateTemplateSettingsMutationSchema, } from './schema'; @@ -296,4 +298,30 @@ export const templateRouter = router({ throw AppError.parseErrorToTRPCError(error); } }), + + moveTemplateToTeam: authenticatedProcedure + .input(ZMoveTemplatesToTeamSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, teamId } = input; + const userId = ctx.user.id; + + return await moveTemplateToTeam({ + templateId, + teamId, + userId, + }); + } catch (err) { + console.error(err); + + if (err instanceof TRPCError) { + throw err; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to move this template. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 2e81f822e..80280b406 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -110,6 +110,11 @@ export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({ id: z.number().min(1), }); +export const ZMoveTemplatesToTeamSchema = z.object({ + templateId: z.number(), + teamId: z.number(), +}); + export type TCreateTemplateMutationSchema = z.infer; export type TCreateDocumentFromTemplateMutationSchema = z.infer< typeof ZCreateDocumentFromTemplateMutationSchema @@ -119,3 +124,4 @@ export type TDeleteTemplateMutationSchema = z.infer; +export type TMoveTemplatesToSchema = z.infer;