diff --git a/apps/remix/app/components/dialogs/ai-features-enable-dialog.tsx b/apps/remix/app/components/dialogs/ai-features-enable-dialog.tsx new file mode 100644 index 000000000..20c2c1e8e --- /dev/null +++ b/apps/remix/app/components/dialogs/ai-features-enable-dialog.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; + +import { useCurrentTeam } from '~/providers/team'; + +type AiFeaturesEnableDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onEnabled: () => void; +}; + +export const AiFeaturesEnableDialog = ({ + open, + onOpenChange, + onEnabled, +}: AiFeaturesEnableDialogProps) => { + const { t } = useLingui(); + + const team = useCurrentTeam(); + const organisation = useCurrentOrganisation(); + + const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN; + const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN; + const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin; + + const [error, setError] = useState(null); + + const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } = + trpc.team.settings.update.useMutation(); + const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } = + trpc.organisation.settings.update.useMutation(); + + const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings; + + const onEnableClick = async () => { + if (!canEnableAiFeatures) { + return; + } + + setError(null); + + try { + if (isTeamAdmin) { + await updateTeamSettings({ + teamId: team.id, + data: { aiFeaturesEnabled: true }, + }); + } else { + await updateOrganisationSettings({ + organisationId: organisation.id, + data: { aiFeaturesEnabled: true }, + }); + } + + onEnabled(); + onOpenChange(false); + } catch (err) { + console.error('Failed to enable AI features', err); + setError( + err instanceof Error + ? err.message + : t`We couldn't enable AI features right now. Please try again.`, + ); + } + }; + + return ( + + + + + Enable AI features + + + +
+

+ + Turn on AI detection to automatically find recipients and fields in your documents. AI + providers do not retain your data for training. + +

+ + + + + Your document content will be sent securely to our AI provider solely for detection + and will not be stored or used for training. + + + + + {canEnableAiFeatures ? ( +

+ + You're an admin. You can enable AI features for this team right away. Everyone on + the team will see AI detection once enabled. + +

+ ) : ( +

+ + AI features are disabled for your team. Please ask your team owner or organisation + owner to enable them. + +

+ )} + + {error ?

{error}

: null} +
+ + + + + {canEnableAiFeatures ? ( + + ) : null} + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index d1a1edca1..548ec7420 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { FileTextIcon, SparklesIcon } from 'lucide-react'; -import { Link, useSearchParams } from 'react-router'; +import { Link, useRevalidator, useSearchParams } from 'react-router'; import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; @@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; +import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog'; import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form'; import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form'; @@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => { const { _ } = useLingui(); const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false); + const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); + const { revalidate } = useRevalidator(); const selectedField = useMemo( () => structuredClone(editorFields.selectedField), @@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => { editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null); }, []); + const onDetectClick = () => { + if (!team.preferences.aiFeaturesEnabled) { + setIsAiEnableDialogOpen(true); + return; + } + + setIsAiFieldDialogOpen(true); + }; + + const onAiFeaturesEnabled = () => { + void revalidate().then(() => { + setIsAiEnableDialogOpen(false); + setIsAiFieldDialogOpen(true); + }); + }; + return (
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => { selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} /> - {team.preferences.aiFeaturesEnabled && ( - <> - + - - - )} + + + {/* Field details section. */} diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index 5b07d91bf..7b17e9d97 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@ import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react'; import { useFieldArray, useForm, useWatch } from 'react-hook-form'; -import { useSearchParams } from 'react-router'; +import { useRevalidator, useSearchParams } from 'react-router'; import { isDeepEqual, prop, sortBy } from 'remeda'; import { z } from 'zod'; @@ -63,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; @@ -97,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => { const [searchParams, setSearchParams] = useSearchParams(); const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); // AI recipient detection dialog state const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true'); + const { revalidate } = useRevalidator(); const onAiDialogOpenChange = (open: boolean) => { + if (open && !team.preferences.aiFeaturesEnabled) { + setIsAiEnableDialogOpen(true); + setIsAiDialogOpen(false); + return; + } + setIsAiDialogOpen(open); if (!open && searchParams.get('ai') === 'true') { @@ -118,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => { } }; + const onDetectRecipientsClick = () => { + if (!team.preferences.aiFeaturesEnabled) { + setIsAiEnableDialogOpen(true); + return; + } + + setIsAiDialogOpen(true); + }; + + const onAiFeaturesEnabled = () => { + void revalidate().then(() => { + setIsAiEnableDialogOpen(false); + setIsAiDialogOpen(true); + }); + }; + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); const initialId = useId(); @@ -632,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
- {team.preferences.aiFeaturesEnabled && ( - - - - + + + + - + + {team.preferences.aiFeaturesEnabled ? ( Detect recipients with AI - - - )} + ) : ( + Enable AI detection + )} + +
-
+
Created on: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component disabled={envelope.recipients.some( (recipient) => recipient.signingStatus !== SigningStatus.SIGNED && - recipient.signingStatus !== SigningStatus.REJECTED, + recipient.signingStatus !== SigningStatus.REJECTED && + recipient.role !== RecipientRole.CC, )} onClick={() => resealDocument({ id: envelope.id })} > diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index ccd5754bd..8759dcbe3 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -92,9 +92,23 @@ export const run = async ({ teamId: envelope.teamId, }); + // Ensure all CC recipients are marked as signed + await prisma.recipient.updateMany({ + where: { + envelopeId: envelope.id, + role: RecipientRole.CC, + }, + data: { + signingStatus: SigningStatus.SIGNED, + }, + }); + const isComplete = envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) || - envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED); + envelope.recipients.every( + (recipient) => + recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC, + ); if (!isComplete) { throw new AppError(AppErrorCode.UNKNOWN_ERROR, {