feat(automation): multi-domain rules and webhook scope UI
Extend automations to drive and contacts with context-aware triggers, conditions, and actions. Webhooks can filter event types and scopes per domain.
This commit is contained in:
parent
6ec95262af
commit
20552a34ff
@ -0,0 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo } from 'react'
|
||||||
|
import type { AutomationDomain } from '@/lib/mail-automation/domains'
|
||||||
|
import { inferDomainsFromTriggers } from '@/lib/mail-automation/domains'
|
||||||
|
import type { TriggerOrGroup } from '@/lib/mail-automation/types'
|
||||||
|
|
||||||
|
const AutomationDomainContext = createContext<AutomationDomain[]>(['mail'])
|
||||||
|
|
||||||
|
export function AutomationDomainProvider({
|
||||||
|
triggers,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
triggers: TriggerOrGroup
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const domains = useMemo(() => inferDomainsFromTriggers(triggers), [triggers])
|
||||||
|
return (
|
||||||
|
<AutomationDomainContext.Provider value={domains}>{children}</AutomationDomainContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomationDomains(): AutomationDomain[] {
|
||||||
|
return useContext(AutomationDomainContext)
|
||||||
|
}
|
||||||
@ -1,13 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Play } from 'lucide-react'
|
import { Play } from 'lucide-react'
|
||||||
import { useSimulateMailRule } from '@/lib/api/hooks/use-mail-automation-queries'
|
import { useSimulateMailRule } from '@/lib/api/hooks/use-mail-automation-queries'
|
||||||
import type { RuleEditorState, RuleSimulationResult } from '@/lib/mail-automation/types'
|
import type { RuleEditorState, RuleSimulationResult } from '@/lib/mail-automation/types'
|
||||||
import { DEFAULT_SIMULATION_MESSAGE, workflowToApiPayload } from '@/lib/mail-automation/defaults'
|
import {
|
||||||
|
DEFAULT_SIMULATION_CONTACT,
|
||||||
|
DEFAULT_SIMULATION_DRIVE_FILE,
|
||||||
|
DEFAULT_SIMULATION_MESSAGE,
|
||||||
|
workflowToApiPayload,
|
||||||
|
} from '@/lib/mail-automation/defaults'
|
||||||
|
import { inferDomainsFromTriggers } from '@/lib/mail-automation/domains'
|
||||||
|
import { AUTOMATION_DOMAIN_LABELS } from '@/lib/mail-automation/domains'
|
||||||
|
|
||||||
interface RuleSimulatorPanelProps {
|
interface RuleSimulatorPanelProps {
|
||||||
state: RuleEditorState
|
state: RuleEditorState
|
||||||
@ -17,8 +24,15 @@ interface RuleSimulatorPanelProps {
|
|||||||
export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
||||||
const simulate = useSimulateMailRule()
|
const simulate = useSimulateMailRule()
|
||||||
const [message, setMessage] = useState(DEFAULT_SIMULATION_MESSAGE)
|
const [message, setMessage] = useState(DEFAULT_SIMULATION_MESSAGE)
|
||||||
|
const [driveFile, setDriveFile] = useState(DEFAULT_SIMULATION_DRIVE_FILE)
|
||||||
|
const [contact, setContact] = useState(DEFAULT_SIMULATION_CONTACT)
|
||||||
const [result, setResult] = useState<RuleSimulationResult | null>(null)
|
const [result, setResult] = useState<RuleSimulationResult | null>(null)
|
||||||
|
|
||||||
|
const domains = useMemo(
|
||||||
|
() => inferDomainsFromTriggers(state.workflow.triggers),
|
||||||
|
[state.workflow.triggers]
|
||||||
|
)
|
||||||
|
|
||||||
async function runSimulation() {
|
async function runSimulation() {
|
||||||
const payload = workflowToApiPayload(state)
|
const payload = workflowToApiPayload(state)
|
||||||
const res = await simulate.mutateAsync({
|
const res = await simulate.mutateAsync({
|
||||||
@ -38,7 +52,18 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border border-border bg-muted/10 p-3">
|
<div className="space-y-3 rounded-lg border border-border bg-muted/10 p-3">
|
||||||
<p className="text-xs font-medium">Tester avec un message exemple</p>
|
<p className="text-xs font-medium">
|
||||||
|
Tester avec un événement exemple
|
||||||
|
{domains.length > 0 ? (
|
||||||
|
<span className="ml-1 font-normal text-muted-foreground">
|
||||||
|
({domains.map((d) => AUTOMATION_DOMAIN_LABELS[d]).join(', ')})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{domains.includes('mail') ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-border/50 p-2">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Mail</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">De</Label>
|
<Label className="text-[10px]">De</Label>
|
||||||
@ -57,6 +82,64 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
|||||||
onChange={(e) => setMessage({ ...message, body_text: e.target.value })}
|
onChange={(e) => setMessage({ ...message, body_text: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{domains.includes('drive') ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-border/50 p-2">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Drive</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Nom fichier</Label>
|
||||||
|
<Input className="h-8 text-xs" value={driveFile.file_name} onChange={(e) => setDriveFile({ ...driveFile, file_name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Chemin</Label>
|
||||||
|
<Input className="h-8 text-xs" value={driveFile.file_path} onChange={(e) => setDriveFile({ ...driveFile, file_path: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Type MIME</Label>
|
||||||
|
<Input className="h-8 text-xs" value={driveFile.mime_type} onChange={(e) => setDriveFile({ ...driveFile, mime_type: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Taille (octets)</Label>
|
||||||
|
<Input type="number" className="h-8 text-xs" value={driveFile.file_size} onChange={(e) => setDriveFile({ ...driveFile, file_size: Number(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{domains.includes('contacts') ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-border/50 p-2">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Contacts</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Nom</Label>
|
||||||
|
<Input className="h-8 text-xs" value={contact.name} onChange={(e) => setContact({ ...contact, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">E-mail</Label>
|
||||||
|
<Input className="h-8 text-xs" value={contact.email} onChange={(e) => setContact({ ...contact, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Téléphone</Label>
|
||||||
|
<Input className="h-8 text-xs" value={contact.phone} onChange={(e) => setContact({ ...contact, phone: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Organisation</Label>
|
||||||
|
<Input className="h-8 text-xs" value={contact.org} onChange={(e) => setContact({ ...contact, org: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{domains.includes('drive') || domains.includes('contacts') ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">
|
||||||
|
La simulation API reste orientée mail ; l'exécution réelle Drive et contacts est active
|
||||||
|
côté serveur à la réception des événements.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button type="button" size="sm" disabled={simulate.isPending} onClick={runSimulation}>
|
<Button type="button" size="sm" disabled={simulate.isPending} onClick={runSimulation}>
|
||||||
<Play className="mr-1 size-3.5" />
|
<Play className="mr-1 size-3.5" />
|
||||||
Simuler
|
Simuler
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { Plus, Variable } from 'lucide-react'
|
|||||||
import { workflowNodeTypes } from './workflow-nodes'
|
import { workflowNodeTypes } from './workflow-nodes'
|
||||||
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
|
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
|
||||||
import { WorkflowNodeInspector } from './workflow-node-inspector'
|
import { WorkflowNodeInspector } from './workflow-node-inspector'
|
||||||
|
import { AutomationDomainProvider } from './automation-domain-context'
|
||||||
import {
|
import {
|
||||||
PALETTE_NODE_TYPES,
|
PALETTE_NODE_TYPES,
|
||||||
NODE_TYPE_LABELS,
|
NODE_TYPE_LABELS,
|
||||||
@ -166,6 +167,7 @@ function RuleWorkflowEditorInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AutomationDomainProvider triggers={state.workflow.triggers}>
|
||||||
<div className="flex min-h-[520px] flex-col gap-3 lg:flex-row">
|
<div className="flex min-h-[520px] flex-col gap-3 lg:flex-row">
|
||||||
<aside className="w-full shrink-0 space-y-2 lg:w-52">
|
<aside className="w-full shrink-0 space-y-2 lg:w-52">
|
||||||
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
|
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
|
||||||
@ -265,11 +267,13 @@ function RuleWorkflowEditorInner({
|
|||||||
<WorkflowNodeInspector
|
<WorkflowNodeInspector
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
allRules={allRules}
|
allRules={allRules}
|
||||||
|
triggers={state.workflow.triggers}
|
||||||
onUpdate={updateNodeData}
|
onUpdate={updateNodeData}
|
||||||
onDelete={deleteNode}
|
onDelete={deleteNode}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</AutomationDomainProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { useContactBooks } from "@/lib/api/hooks/use-contact-queries"
|
||||||
|
import type { WebhookContactsScope } from "@/lib/mail-automation/webhook-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function WebhookContactsScopeEditor({
|
||||||
|
scope,
|
||||||
|
onChange,
|
||||||
|
enabled,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
scope: WebhookContactsScope
|
||||||
|
onChange: (scope: WebhookContactsScope) => void
|
||||||
|
enabled: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { data: booksRaw, isLoading } = useContactBooks()
|
||||||
|
const books = Array.isArray(booksRaw)
|
||||||
|
? booksRaw
|
||||||
|
: booksRaw && typeof booksRaw === "object" && "address_books" in booksRaw
|
||||||
|
? (booksRaw as { address_books: { id: string; name: string }[] }).address_books ?? []
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
function toggleBook(bookId: string) {
|
||||||
|
const ids = scope.book_ids.includes(bookId)
|
||||||
|
? scope.book_ids.filter((id) => id !== bookId)
|
||||||
|
: [...scope.book_ids, bookId]
|
||||||
|
onChange({ all_books: false, book_ids: ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className={cn("space-y-3 rounded-md border border-border p-3", className)}>
|
||||||
|
<legend className="px-1 text-sm font-medium text-foreground">
|
||||||
|
Périmètre contacts — carnets
|
||||||
|
</legend>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={scope.all_books}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({
|
||||||
|
all_books: checked === true,
|
||||||
|
book_ids: checked === true ? [] : scope.book_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Tous les carnets
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
Le webhook ne se déclenchera que pour les événements contacts sélectionnés.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!scope.all_books && (
|
||||||
|
<div className="space-y-2 pl-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Carnets autorisés</Label>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Chargement…</p>
|
||||||
|
) : books.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Aucun carnet configuré.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{books.map((book) => (
|
||||||
|
<li key={book.id}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2.5 py-2 hover:bg-muted/40">
|
||||||
|
<Checkbox
|
||||||
|
checked={scope.book_ids.includes(book.id)}
|
||||||
|
onCheckedChange={() => toggleBook(book.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{book.name || book.id}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { ApiTokenDriveScopeEditor } from "@/components/gmail/settings/automation/api-token-drive-scope-editor"
|
||||||
|
import { ApiTokenMailScopeEditor } from "@/components/gmail/settings/automation/api-token-mail-scope-editor"
|
||||||
|
import { WebhookContactsScopeEditor } from "@/components/gmail/settings/automation/webhook-contacts-scope-editor"
|
||||||
|
import {
|
||||||
|
AUTOMATION_DOMAIN_LABELS,
|
||||||
|
type AutomationDomain,
|
||||||
|
} from "@/lib/mail-automation/domains"
|
||||||
|
import type { TriggerType } from "@/lib/mail-automation/types"
|
||||||
|
import {
|
||||||
|
hasContactsWebhookEvents,
|
||||||
|
hasDriveWebhookEvents,
|
||||||
|
hasMailWebhookEvents,
|
||||||
|
WEBHOOK_EVENT_OPTIONS,
|
||||||
|
} from "@/lib/mail-automation/webhook-config"
|
||||||
|
import type { ApiTokenDriveScope, ApiTokenMailScope } from "@/lib/api/types"
|
||||||
|
import type { WebhookContactsScope } from "@/lib/mail-automation/webhook-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DOMAINS: AutomationDomain[] = ["mail", "drive", "contacts"]
|
||||||
|
|
||||||
|
export function WebhookEventScopeEditor({
|
||||||
|
eventTypes,
|
||||||
|
onEventTypesChange,
|
||||||
|
mailScope,
|
||||||
|
onMailScopeChange,
|
||||||
|
driveScope,
|
||||||
|
onDriveScopeChange,
|
||||||
|
contactsScope,
|
||||||
|
onContactsScopeChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
eventTypes: TriggerType[]
|
||||||
|
onEventTypesChange: (types: TriggerType[]) => void
|
||||||
|
mailScope: ApiTokenMailScope
|
||||||
|
onMailScopeChange: (scope: ApiTokenMailScope) => void
|
||||||
|
driveScope: ApiTokenDriveScope
|
||||||
|
onDriveScopeChange: (scope: ApiTokenDriveScope) => void
|
||||||
|
contactsScope: WebhookContactsScope
|
||||||
|
onContactsScopeChange: (scope: WebhookContactsScope) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
function toggleEvent(type: TriggerType) {
|
||||||
|
if (eventTypes.includes(type)) {
|
||||||
|
onEventTypesChange(eventTypes.filter((t) => t !== type))
|
||||||
|
} else {
|
||||||
|
onEventTypesChange([...eventTypes, type])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Événements déclencheurs</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Le webhook part uniquement pour les événements cochés, dans le périmètre défini ci-dessous.
|
||||||
|
</p>
|
||||||
|
{DOMAINS.map((domain) => {
|
||||||
|
const options = WEBHOOK_EVENT_OPTIONS.filter((o) => o.domain === domain)
|
||||||
|
return (
|
||||||
|
<fieldset key={domain} className="rounded-md border border-border p-3">
|
||||||
|
<legend className="px-1 text-xs font-medium text-foreground">
|
||||||
|
{AUTOMATION_DOMAIN_LABELS[domain]}
|
||||||
|
</legend>
|
||||||
|
<ul className="mt-2 space-y-1.5">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<li key={opt.value}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={eventTypes.includes(opt.value)}
|
||||||
|
onCheckedChange={() => toggleEvent(opt.value)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApiTokenMailScopeEditor
|
||||||
|
enabled={hasMailWebhookEvents(eventTypes)}
|
||||||
|
scope={mailScope}
|
||||||
|
onChange={onMailScopeChange}
|
||||||
|
/>
|
||||||
|
<ApiTokenDriveScopeEditor
|
||||||
|
enabled={hasDriveWebhookEvents(eventTypes)}
|
||||||
|
scope={driveScope}
|
||||||
|
onChange={onDriveScopeChange}
|
||||||
|
/>
|
||||||
|
<WebhookContactsScopeEditor
|
||||||
|
enabled={hasContactsWebhookEvents(eventTypes)}
|
||||||
|
scope={contactsScope}
|
||||||
|
onChange={onContactsScopeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,11 +4,17 @@ import { useCallback, useState } from "react"
|
|||||||
import { Check, Copy } from "lucide-react"
|
import { Check, Copy } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
WEBHOOK_TEMPLATE_VARIABLE_GROUPS,
|
webhookVariableGroupsForDomains,
|
||||||
type WebhookTemplateVariable,
|
type WebhookTemplateVariable,
|
||||||
} from "@/lib/mail-automation/webhook-template-variables"
|
} from "@/lib/mail-automation/webhook-template-variables"
|
||||||
|
import {
|
||||||
|
AUTOMATION_DOMAIN_LABELS,
|
||||||
|
type AutomationDomain,
|
||||||
|
} from "@/lib/mail-automation/domains"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DOMAIN_TABS: Array<AutomationDomain | "all"> = ["all", "mail", "drive", "contacts"]
|
||||||
|
|
||||||
function VariableChip({
|
function VariableChip({
|
||||||
variable,
|
variable,
|
||||||
copied,
|
copied,
|
||||||
@ -43,6 +49,12 @@ function VariableChip({
|
|||||||
|
|
||||||
export function WebhookTemplateVariablesPanel() {
|
export function WebhookTemplateVariablesPanel() {
|
||||||
const [copiedToken, setCopiedToken] = useState<string | null>(null)
|
const [copiedToken, setCopiedToken] = useState<string | null>(null)
|
||||||
|
const [filter, setFilter] = useState<AutomationDomain | "all">("all")
|
||||||
|
|
||||||
|
const groups =
|
||||||
|
filter === "all"
|
||||||
|
? webhookVariableGroupsForDomains(["mail", "drive", "contacts"])
|
||||||
|
: webhookVariableGroupsForDomains([filter])
|
||||||
|
|
||||||
const copyToken = useCallback(async (token: string) => {
|
const copyToken = useCallback(async (token: string) => {
|
||||||
try {
|
try {
|
||||||
@ -59,17 +71,33 @@ export function WebhookTemplateVariablesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
<div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Variables du template</p>
|
<p className="text-sm font-medium">Variables du template</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Insérez ces variables dans votre JSON{" "}
|
Variables selon le type d'événement (mail, Drive, contacts). Les variables communes
|
||||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">body_template</code>.
|
($event.*, $date) fonctionnent partout.
|
||||||
Cliquez sur une puce pour la copier dans le presse-papiers.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{DOMAIN_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(tab)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-2 py-0.5 text-[11px] transition-colors",
|
||||||
|
filter === tab
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-background text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === "all" ? "Tout" : AUTOMATION_DOMAIN_LABELS[tab]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{WEBHOOK_TEMPLATE_VARIABLE_GROUPS.map((group) => (
|
{groups.map((group) => (
|
||||||
<section key={group.id} className="space-y-2">
|
<section key={group.id} className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-medium">{group.label}</h3>
|
<h3 className="text-xs font-medium">{group.label}</h3>
|
||||||
|
|||||||
@ -1,62 +1,134 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Trash2 } from "lucide-react"
|
import { Pencil, Trash2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import {
|
import {
|
||||||
useMailWebhooks,
|
useMailWebhooks,
|
||||||
useCreateMailWebhook,
|
useCreateMailWebhook,
|
||||||
|
useUpdateMailWebhook,
|
||||||
useDeleteMailWebhook,
|
useDeleteMailWebhook,
|
||||||
} from "@/lib/api/hooks/use-mail-automation-queries"
|
} from "@/lib/api/hooks/use-mail-automation-queries"
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||||
import { WebhookTemplateVariablesPanel } from "@/components/gmail/settings/automation/webhook-template-variables-panel"
|
import { WebhookTemplateVariablesPanel } from "@/components/gmail/settings/automation/webhook-template-variables-panel"
|
||||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||||
|
import { WebhookEventScopeEditor } from "@/components/gmail/settings/automation/webhook-event-scope-editor"
|
||||||
|
import { WEBHOOK_DEFAULT_TEMPLATES } from "@/lib/mail-automation/webhook-template-variables"
|
||||||
|
import {
|
||||||
|
AUTOMATION_DOMAIN_LABELS,
|
||||||
|
type AutomationDomain,
|
||||||
|
} from "@/lib/mail-automation/domains"
|
||||||
|
import {
|
||||||
|
createDefaultWebhookForm,
|
||||||
|
defaultEventTypesForDomain,
|
||||||
|
type WebhookFormState,
|
||||||
|
} from "@/lib/mail-automation/webhook-config"
|
||||||
|
import { TRIGGER_LABELS } from "@/lib/mail-automation/domains"
|
||||||
|
import type { ApiWebhook } from "@/lib/api/types"
|
||||||
|
import type { TriggerType } from "@/lib/mail-automation/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function webhookToForm(hook: ApiWebhook): WebhookFormState {
|
||||||
|
return {
|
||||||
|
name: hook.name,
|
||||||
|
url: hook.url,
|
||||||
|
template: hook.body_template ?? WEBHOOK_DEFAULT_TEMPLATES.mail,
|
||||||
|
eventTypes: (hook.event_types?.length ? hook.event_types : ["message_received"]) as TriggerType[],
|
||||||
|
mailScope: hook.mail_scope ?? { all_accounts: true, account_ids: [] },
|
||||||
|
driveScope: hook.drive_scope ?? { all_folders: true, folder_paths: [] },
|
||||||
|
contactsScope: hook.contacts_scope ?? { all_books: true, book_ids: [] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeWebhook(hook: ApiWebhook): string {
|
||||||
|
const types = hook.event_types ?? []
|
||||||
|
if (types.length === 0) return "Aucun événement"
|
||||||
|
const labels = types.slice(0, 3).map((t) => TRIGGER_LABELS[t as TriggerType] ?? t)
|
||||||
|
const extra = types.length > 3 ? ` +${types.length - 3}` : ""
|
||||||
|
return labels.join(", ") + extra
|
||||||
|
}
|
||||||
|
|
||||||
export function WebhooksPanel() {
|
export function WebhooksPanel() {
|
||||||
const { ready, authenticated } = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
const { data: webhooks = [], isFetching, isError, refetch, isPending } = useMailWebhooks()
|
const { data: webhooks = [], isFetching, isError, refetch, isPending } = useMailWebhooks()
|
||||||
const createWebhook = useCreateMailWebhook()
|
const createWebhook = useCreateMailWebhook()
|
||||||
|
const updateWebhook = useUpdateMailWebhook()
|
||||||
const deleteWebhook = useDeleteMailWebhook()
|
const deleteWebhook = useDeleteMailWebhook()
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [url, setUrl] = useState("")
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [template, setTemplate] = useState(
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
'{"text":"Nouveau mail de $sender.name : $subject"}'
|
const [presetDomain, setPresetDomain] = useState<AutomationDomain>("mail")
|
||||||
)
|
const [form, setForm] = useState<WebhookFormState>(() => createDefaultWebhookForm("mail"))
|
||||||
|
|
||||||
const showInitialLoad = ready && authenticated && isPending && webhooks.length === 0
|
const showInitialLoad = ready && authenticated && isPending && webhooks.length === 0
|
||||||
|
|
||||||
|
const editorTitle = editingId ? "Modifier le webhook" : "Nouveau webhook"
|
||||||
|
|
||||||
|
function openCreate(domain: AutomationDomain) {
|
||||||
|
setEditingId(null)
|
||||||
|
setPresetDomain(domain)
|
||||||
|
setForm({
|
||||||
|
...createDefaultWebhookForm(domain),
|
||||||
|
template: WEBHOOK_DEFAULT_TEMPLATES[domain],
|
||||||
|
})
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(hook: ApiWebhook) {
|
||||||
|
setEditingId(hook.id)
|
||||||
|
setForm(webhookToForm(hook))
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
method: "POST" as const,
|
||||||
|
body_template: form.template,
|
||||||
|
event_types: form.eventTypes,
|
||||||
|
mail_scope: form.mailScope,
|
||||||
|
drive_scope: form.driveScope,
|
||||||
|
contacts_scope: form.contactsScope,
|
||||||
|
}
|
||||||
|
if (editingId) {
|
||||||
|
await updateWebhook.mutateAsync({ webhookId: editingId, ...payload })
|
||||||
|
} else {
|
||||||
|
await createWebhook.mutateAsync(payload)
|
||||||
|
}
|
||||||
|
setEditorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave =
|
||||||
|
form.name.trim().length > 0 &&
|
||||||
|
form.url.trim().length > 0 &&
|
||||||
|
form.eventTypes.length > 0 &&
|
||||||
|
!createWebhook.isPending &&
|
||||||
|
!updateWebhook.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["mail", "drive", "contacts"] as AutomationDomain[]).map((domain) => (
|
||||||
|
<Button key={domain} type="button" size="sm" variant="outline" onClick={() => openCreate(domain)}>
|
||||||
|
Webhook {AUTOMATION_DOMAIN_LABELS[domain]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AutomationTabMasonry columns={2}>
|
<AutomationTabMasonry columns={2}>
|
||||||
<WebhookTemplateVariablesPanel />
|
<WebhookTemplateVariablesPanel />
|
||||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
|
||||||
<Label className="text-xs">Nouveau webhook</Label>
|
|
||||||
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
|
||||||
<textarea
|
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
|
||||||
value={template}
|
|
||||||
onChange={(e) => setTemplate(e.target.value)}
|
|
||||||
placeholder="body_template JSON"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
createWebhook.mutate({
|
|
||||||
name: name.trim(),
|
|
||||||
url: url.trim(),
|
|
||||||
method: "POST",
|
|
||||||
body_template: template,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Créer le webhook
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showInitialLoad ? (
|
{showInitialLoad ? (
|
||||||
<p className="text-sm text-muted-foreground">Chargement…</p>
|
<p className="text-sm text-muted-foreground">Chargement…</p>
|
||||||
@ -65,27 +137,97 @@ export function WebhooksPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
{webhooks.map((hook) => (
|
{webhooks.map((hook) => (
|
||||||
<li
|
<li key={hook.id} className="flex items-start justify-between gap-2 px-3 py-3">
|
||||||
key={hook.id}
|
|
||||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{hook.name}</p>
|
<p className="text-sm font-medium">{hook.name}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
|
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">{summarizeWebhook(hook)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex shrink-0 gap-1">
|
||||||
type="button"
|
<Button type="button" variant="ghost" size="icon" onClick={() => openEdit(hook)}>
|
||||||
variant="ghost"
|
<Pencil className="size-4" />
|
||||||
size="icon"
|
</Button>
|
||||||
onClick={() => deleteWebhook.mutate(hook.id)}
|
<Button type="button" variant="ghost" size="icon" onClick={() => deleteWebhook.mutate(hook.id)}>
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</AutomationTabMasonry>
|
</AutomationTabMasonry>
|
||||||
|
|
||||||
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="border-b border-border px-4 py-3">
|
||||||
|
<DialogTitle className="text-base">{editorTitle}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
|
{!editingId ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(["mail", "drive", "contacts"] as AutomationDomain[]).map((domain) => (
|
||||||
|
<button
|
||||||
|
key={domain}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPresetDomain(domain)
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
eventTypes: defaultEventTypesForDomain(domain),
|
||||||
|
template: WEBHOOK_DEFAULT_TEMPLATES[domain],
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-2 py-0.5 text-[11px] transition-colors",
|
||||||
|
presetDomain === domain
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-background text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Modèle {AUTOMATION_DOMAIN_LABELS[domain]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Nom</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">URL HTTPS</Label>
|
||||||
|
<Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">body_template JSON</Label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
value={form.template}
|
||||||
|
onChange={(e) => setForm({ ...form, template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WebhookEventScopeEditor
|
||||||
|
eventTypes={form.eventTypes}
|
||||||
|
onEventTypesChange={(eventTypes) => setForm({ ...form, eventTypes })}
|
||||||
|
mailScope={form.mailScope}
|
||||||
|
onMailScopeChange={(mailScope) => setForm({ ...form, mailScope })}
|
||||||
|
driveScope={form.driveScope}
|
||||||
|
onDriveScopeChange={(driveScope) => setForm({ ...form, driveScope })}
|
||||||
|
contactsScope={form.contactsScope}
|
||||||
|
onContactsScopeChange={(contactsScope) => setForm({ ...form, contactsScope })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!canSave} onClick={handleSave}>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,24 @@ import { Label } from '@/components/ui/label'
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
import { NODE_TYPE_LABELS } from '@/lib/mail-automation/node-definitions'
|
||||||
import {
|
import {
|
||||||
ACTION_TYPES,
|
actionTypesForDomains,
|
||||||
CONDITION_FIELDS,
|
AUTOMATION_DOMAIN_LABELS,
|
||||||
NODE_TYPE_LABELS,
|
conditionFieldsForDomains,
|
||||||
} from '@/lib/mail-automation/node-definitions'
|
inferDomainsFromTriggers,
|
||||||
|
primaryDomainFromTriggers,
|
||||||
|
} from '@/lib/mail-automation/domains'
|
||||||
|
import { useAutomationDomains } from './automation-domain-context'
|
||||||
|
import { isBooleanConditionField, isLabelConditionField } from '@/lib/mail-automation/condition-helpers'
|
||||||
import {
|
import {
|
||||||
actionValueSuggestionKind,
|
actionValueSuggestionKind,
|
||||||
conditionValueSuggestionKind,
|
conditionValueSuggestionKind,
|
||||||
@ -44,6 +51,7 @@ interface WorkflowNodeInspectorProps {
|
|||||||
allRules: ApiRule[]
|
allRules: ApiRule[]
|
||||||
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
|
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
|
||||||
onDelete: (nodeId: string) => void
|
onDelete: (nodeId: string) => void
|
||||||
|
triggers?: import('@/lib/mail-automation/types').TriggerOrGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowNodeInspector({
|
export function WorkflowNodeInspector({
|
||||||
@ -51,7 +59,16 @@ export function WorkflowNodeInspector({
|
|||||||
allRules,
|
allRules,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
triggers,
|
||||||
}: WorkflowNodeInspectorProps) {
|
}: WorkflowNodeInspectorProps) {
|
||||||
|
const contextDomains = useAutomationDomains()
|
||||||
|
const activeDomains = triggers ? inferDomainsFromTriggers(triggers) : contextDomains
|
||||||
|
const conditionFields = conditionFieldsForDomains(activeDomains)
|
||||||
|
const actionTypes = actionTypesForDomains(activeDomains)
|
||||||
|
const primaryDomain = primaryDomainFromTriggers(
|
||||||
|
triggers ?? { operator: 'or', groups: [{ operator: 'and', items: [] }] }
|
||||||
|
)
|
||||||
|
|
||||||
if (!node || node.type === 'start' || node.type === 'end') {
|
if (!node || node.type === 'start' || node.type === 'end') {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
<div className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
||||||
@ -88,6 +105,7 @@ export function WorkflowNodeInspector({
|
|||||||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||||
{type === 'condition' || type === 'label_check' ? (
|
{type === 'condition' || type === 'label_check' ? (
|
||||||
<ConditionEditor
|
<ConditionEditor
|
||||||
|
fields={conditionFields}
|
||||||
data={
|
data={
|
||||||
type === 'label_check'
|
type === 'label_check'
|
||||||
? {
|
? {
|
||||||
@ -101,13 +119,18 @@ export function WorkflowNodeInspector({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{type === 'switch' ? (
|
{type === 'switch' ? (
|
||||||
<SwitchEditor data={data as unknown as SwitchNodeData} onChange={patch} />
|
<SwitchEditor fields={conditionFields} data={data as unknown as SwitchNodeData} onChange={patch} />
|
||||||
) : null}
|
) : null}
|
||||||
{type === 'llm_check' ? (
|
{type === 'llm_check' ? (
|
||||||
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
|
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
|
||||||
) : null}
|
) : null}
|
||||||
{type === 'actions' ? (
|
{type === 'actions' ? (
|
||||||
<ActionsEditor data={data as unknown as ActionsNodeData} onChange={patch} />
|
<ActionsEditor
|
||||||
|
actionTypes={actionTypes}
|
||||||
|
primaryDomain={primaryDomain}
|
||||||
|
data={data as unknown as ActionsNodeData}
|
||||||
|
onChange={patch}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{type === 'set_var' ? (
|
{type === 'set_var' ? (
|
||||||
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
|
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
|
||||||
@ -126,9 +149,11 @@ export function WorkflowNodeInspector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConditionEditor({
|
function ConditionEditor({
|
||||||
|
fields,
|
||||||
data,
|
data,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
||||||
data: ConditionNodeData
|
data: ConditionNodeData
|
||||||
onChange: (p: Partial<ConditionNodeData>) => void
|
onChange: (p: Partial<ConditionNodeData>) => void
|
||||||
}) {
|
}) {
|
||||||
@ -140,30 +165,30 @@ function ConditionEditor({
|
|||||||
onChange({
|
onChange({
|
||||||
field,
|
field,
|
||||||
operator: defaultOperatorForField(field),
|
operator: defaultOperatorForField(field),
|
||||||
value: field === 'has_attachment' ? 'true' : '',
|
value: isBooleanConditionField(field) ? 'true' : '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldSelect
|
<DomainGroupedFieldSelect
|
||||||
label="Champ"
|
label="Champ"
|
||||||
value={data.field}
|
value={data.field}
|
||||||
options={CONDITION_FIELDS}
|
fields={fields}
|
||||||
onChange={(v) => onFieldChange(v as ConditionField)}
|
onChange={(v) => onFieldChange(v as ConditionField)}
|
||||||
/>
|
/>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
label={data.field === 'label' ? 'Mode' : 'Opérateur'}
|
label={isLabelConditionField(data.field) ? 'Mode' : 'Opérateur'}
|
||||||
value={data.operator}
|
value={data.operator}
|
||||||
options={operatorOptions}
|
options={operatorOptions}
|
||||||
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
|
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
{data.field === 'label' ? 'Libellé' : isRegex ? 'Expression régulière' : 'Valeur'}
|
{isLabelConditionField(data.field) ? 'Libellé' : isRegex ? 'Expression régulière' : 'Valeur'}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{data.field === 'has_attachment' ? (
|
{isBooleanConditionField(data.field) ? (
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
label=""
|
label=""
|
||||||
value={data.value || 'true'}
|
value={data.value || 'true'}
|
||||||
@ -188,9 +213,11 @@ function ConditionEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SwitchEditor({
|
function SwitchEditor({
|
||||||
|
fields,
|
||||||
data,
|
data,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
||||||
data: SwitchNodeData
|
data: SwitchNodeData
|
||||||
onChange: (p: Partial<SwitchNodeData>) => void
|
onChange: (p: Partial<SwitchNodeData>) => void
|
||||||
}) {
|
}) {
|
||||||
@ -199,7 +226,12 @@ function SwitchEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldSelect label="Champ" value={data.field} options={CONDITION_FIELDS} onChange={(v) => onChange({ field: v })} />
|
<DomainGroupedFieldSelect
|
||||||
|
label="Champ"
|
||||||
|
value={data.field}
|
||||||
|
fields={fields}
|
||||||
|
onChange={(v) => onChange({ field: v })}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Cas de sortie</Label>
|
<Label className="text-xs">Cas de sortie</Label>
|
||||||
{cases.map((c, i) => (
|
{cases.map((c, i) => (
|
||||||
@ -271,17 +303,23 @@ function LLMEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActionsEditor({
|
function ActionsEditor({
|
||||||
|
actionTypes,
|
||||||
|
primaryDomain,
|
||||||
data,
|
data,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
actionTypes: ReturnType<typeof actionTypesForDomains>
|
||||||
|
primaryDomain: import('@/lib/mail-automation/domains').AutomationDomain
|
||||||
data: ActionsNodeData
|
data: ActionsNodeData
|
||||||
onChange: (p: Partial<ActionsNodeData>) => void
|
onChange: (p: Partial<ActionsNodeData>) => void
|
||||||
}) {
|
}) {
|
||||||
const actions = data.actions ?? []
|
const actions = data.actions ?? []
|
||||||
|
const grouped = ['universal', 'mail', 'drive', 'contacts'] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{actions.map((action, i) => {
|
{actions.map((action, i) => {
|
||||||
const meta = ACTION_TYPES.find((a) => a.value === action.type)
|
const meta = actionTypes.find((a) => a.value === action.type)
|
||||||
const suggestKind = actionValueSuggestionKind(action.type)
|
const suggestKind = actionValueSuggestionKind(action.type)
|
||||||
return (
|
return (
|
||||||
<div key={i} className="space-y-1 rounded border border-border/60 p-2">
|
<div key={i} className="space-y-1 rounded border border-border/60 p-2">
|
||||||
@ -292,9 +330,22 @@ function ActionsEditor({
|
|||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ACTION_TYPES.map((a) => (
|
{grouped.map((group) => {
|
||||||
<SelectItem key={a.value} value={a.value} className="text-xs">{a.label}</SelectItem>
|
const items = actionTypes.filter((a) => a.domain === group)
|
||||||
|
if (items.length === 0) return null
|
||||||
|
const groupLabel =
|
||||||
|
group === 'universal' ? 'Commun' : AUTOMATION_DOMAIN_LABELS[group]
|
||||||
|
return (
|
||||||
|
<SelectGroup key={group}>
|
||||||
|
<SelectLabel className="text-[10px]">{groupLabel}</SelectLabel>
|
||||||
|
{items.map((a) => (
|
||||||
|
<SelectItem key={a.value} value={a.value} className="text-xs">
|
||||||
|
{a.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{meta?.needsValue ? (
|
{meta?.needsValue ? (
|
||||||
@ -315,7 +366,13 @@ function ActionsEditor({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ actions: [...actions, createEmptyAction()] })}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
onClick={() => onChange({ actions: [...actions, createEmptyAction(primaryDomain)] })}
|
||||||
|
>
|
||||||
<Plus className="mr-1 size-3" />
|
<Plus className="mr-1 size-3" />
|
||||||
Ajouter une action
|
Ajouter une action
|
||||||
</Button>
|
</Button>
|
||||||
@ -371,6 +428,42 @@ function CallRuleEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DomainGroupedFieldSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
fields,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
const domains = [...new Set(fields.map((f) => f.domain))]
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label ? <Label className="text-xs">{label}</Label> : null}
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className={label ? 'mt-1 h-8 text-xs' : 'h-8 text-xs'}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectGroup key={domain}>
|
||||||
|
<SelectLabel className="text-[10px]">{AUTOMATION_DOMAIN_LABELS[domain]}</SelectLabel>
|
||||||
|
{fields
|
||||||
|
.filter((f) => f.domain === domain)
|
||||||
|
.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value} className="text-xs">
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FieldSelect({
|
function FieldSelect({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@ -5,11 +5,10 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
NODE_COLORS,
|
NODE_COLORS,
|
||||||
CONDITION_FIELDS,
|
|
||||||
CONDITION_OPERATORS,
|
CONDITION_OPERATORS,
|
||||||
LABEL_CONDITION_OPERATORS,
|
LABEL_CONDITION_OPERATORS,
|
||||||
ACTION_TYPES,
|
|
||||||
} from '@/lib/mail-automation/node-definitions'
|
} from '@/lib/mail-automation/node-definitions'
|
||||||
|
import { actionTypeLabel, conditionFieldLabel } from '@/lib/mail-automation/domains'
|
||||||
import { formatConditionSummary } from '@/lib/mail-automation/condition-helpers'
|
import { formatConditionSummary } from '@/lib/mail-automation/condition-helpers'
|
||||||
import type {
|
import type {
|
||||||
ActionsNodeData,
|
ActionsNodeData,
|
||||||
@ -23,7 +22,7 @@ import type {
|
|||||||
} from '@/lib/mail-automation/types'
|
} from '@/lib/mail-automation/types'
|
||||||
|
|
||||||
function fieldLabel(field: string) {
|
function fieldLabel(field: string) {
|
||||||
return CONDITION_FIELDS.find((f) => f.value === field)?.label ?? field
|
return conditionFieldLabel(field)
|
||||||
}
|
}
|
||||||
|
|
||||||
function opLabel(op: string) {
|
function opLabel(op: string) {
|
||||||
@ -33,7 +32,7 @@ function opLabel(op: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionLabel(type: string) {
|
function actionLabel(type: string) {
|
||||||
return ACTION_TYPES.find((a) => a.value === type)?.label ?? type
|
return actionTypeLabel(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeShell(
|
function nodeShell(
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import { Label } from '@/components/ui/label'
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
@ -16,10 +18,23 @@ import type {
|
|||||||
TriggerOrGroup,
|
TriggerOrGroup,
|
||||||
TriggerType,
|
TriggerType,
|
||||||
} from '@/lib/mail-automation/types'
|
} from '@/lib/mail-automation/types'
|
||||||
import { TRIGGER_LABELS } from '@/lib/mail-automation/node-definitions'
|
import {
|
||||||
|
AUTOMATION_DOMAIN_LABELS,
|
||||||
|
DOMAIN_TRIGGER_TYPES,
|
||||||
|
TRIGGER_LABELS,
|
||||||
|
defaultTriggerForDomain,
|
||||||
|
inferDomainsFromTriggers,
|
||||||
|
triggerDomain,
|
||||||
|
triggerUsesContactLabel,
|
||||||
|
triggerUsesFolderPath,
|
||||||
|
triggerUsesMailFolder,
|
||||||
|
triggerUsesMailLabel,
|
||||||
|
} from '@/lib/mail-automation/domains'
|
||||||
|
import type { AutomationDomain } from '@/lib/mail-automation/domains'
|
||||||
|
import { triggerValueSuggestionKind } from '@/lib/mail-automation/condition-helpers'
|
||||||
import { AutomationSuggestInput } from './automation-suggest-input'
|
import { AutomationSuggestInput } from './automation-suggest-input'
|
||||||
|
|
||||||
const TRIGGER_TYPES: TriggerType[] = ['message_received', 'label_added', 'label_removed']
|
const ALL_DOMAINS: AutomationDomain[] = ['mail', 'drive', 'contacts']
|
||||||
|
|
||||||
interface WorkflowTriggersPanelProps {
|
interface WorkflowTriggersPanelProps {
|
||||||
triggers: TriggerOrGroup
|
triggers: TriggerOrGroup
|
||||||
@ -37,6 +52,9 @@ export function WorkflowTriggersPanel({
|
|||||||
? triggers.groups
|
? triggers.groups
|
||||||
: [{ operator: 'and' as const, items: [{ type: 'message_received' as const }] }]
|
: [{ operator: 'and' as const, items: [{ type: 'message_received' as const }] }]
|
||||||
|
|
||||||
|
const activeDomains = inferDomainsFromTriggers(triggers)
|
||||||
|
const multiDomain = activeDomains.length > 1
|
||||||
|
|
||||||
function updateGroups(next: TriggerAndGroup[]) {
|
function updateGroups(next: TriggerAndGroup[]) {
|
||||||
onChange({
|
onChange({
|
||||||
operator: 'or',
|
operator: 'or',
|
||||||
@ -53,7 +71,7 @@ export function WorkflowTriggersPanel({
|
|||||||
function addOrGroup() {
|
function addOrGroup() {
|
||||||
onChange({
|
onChange({
|
||||||
operator: 'or',
|
operator: 'or',
|
||||||
groups: [...groups, { operator: 'and', items: [{ type: 'message_received' }] }],
|
groups: [...groups, { operator: 'and', items: [defaultTriggerForDomain(activeDomains[0] ?? 'mail')] }],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +92,15 @@ export function WorkflowTriggersPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
|
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
<Label className="text-xs font-medium">Déclencheurs (OU entre groupes, ET dans un groupe)</Label>
|
<Label className="text-xs font-medium">Déclencheurs (OU entre groupes, ET dans un groupe)</Label>
|
||||||
|
{multiDomain ? (
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
Domaines actifs : {activeDomains.map((d) => AUTOMATION_DOMAIN_LABELS[d]).join(', ')} — seules les
|
||||||
|
conditions et actions compatibles sont proposées.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={addOrGroup}>
|
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={addOrGroup}>
|
||||||
<Plus className="mr-1 size-3" />
|
<Plus className="mr-1 size-3" />
|
||||||
Groupe OU
|
Groupe OU
|
||||||
@ -130,7 +156,7 @@ export function WorkflowTriggersPanel({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateGroup(gi, {
|
updateGroup(gi, {
|
||||||
...group,
|
...group,
|
||||||
items: [...group.items, { type: 'message_received' }],
|
items: [...group.items, defaultTriggerForDomain(activeDomains[0] ?? 'mail')],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -154,50 +180,82 @@ function TriggerRow({
|
|||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
function onTypeChange(type: TriggerType) {
|
||||||
|
const domain = triggerDomain(type)
|
||||||
|
const base = defaultTriggerForDomain(domain)
|
||||||
|
onChange({ ...base, type })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="min-w-[140px] flex-1">
|
<div className="min-w-[160px] flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">Type</Label>
|
<Label className="text-[10px] text-muted-foreground">Événement</Label>
|
||||||
<Select
|
<Select value={item.type} disabled={disabled} onValueChange={(v) => onTypeChange(v as TriggerType)}>
|
||||||
value={item.type}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => onChange({ ...item, type: v as TriggerType })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TRIGGER_TYPES.map((t) => (
|
{ALL_DOMAINS.map((domain) => (
|
||||||
|
<SelectGroup key={domain}>
|
||||||
|
<SelectLabel className="text-[10px]">{AUTOMATION_DOMAIN_LABELS[domain]}</SelectLabel>
|
||||||
|
{DOMAIN_TRIGGER_TYPES[domain].map((t) => (
|
||||||
<SelectItem key={t} value={t} className="text-xs">
|
<SelectItem key={t} value={t} className="text-xs">
|
||||||
{TRIGGER_LABELS[t]}
|
{TRIGGER_LABELS[t]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{item.type === 'message_received' ? (
|
{triggerUsesMailFolder(item.type) ? (
|
||||||
<div className="min-w-[120px] flex-1">
|
<div className="min-w-[120px] flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">Dossier (optionnel)</Label>
|
<Label className="text-[10px] text-muted-foreground">Dossier mail (optionnel)</Label>
|
||||||
<AutomationSuggestInput
|
<AutomationSuggestInput
|
||||||
kind="folder_id"
|
kind={triggerValueSuggestionKind('folder_id')}
|
||||||
placeholder="Choisir un dossier…"
|
placeholder="Choisir un dossier…"
|
||||||
value={item.folder_id ?? ''}
|
value={item.folder_id ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(value) => onChange({ ...item, folder_id: value || undefined })}
|
onChange={(value) => onChange({ ...item, folder_id: value || undefined })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
|
{triggerUsesMailLabel(item.type) ? (
|
||||||
<div className="min-w-[120px] flex-1">
|
<div className="min-w-[120px] flex-1">
|
||||||
<Label className="text-[10px] text-muted-foreground">Libellé</Label>
|
<Label className="text-[10px] text-muted-foreground">Libellé mail</Label>
|
||||||
<AutomationSuggestInput
|
<AutomationSuggestInput
|
||||||
kind="label"
|
kind={triggerValueSuggestionKind('label')}
|
||||||
placeholder="Nom libellé"
|
placeholder="Nom libellé"
|
||||||
value={item.label ?? ''}
|
value={item.label ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(value) => onChange({ ...item, label: value || undefined })}
|
onChange={(value) => onChange({ ...item, label: value || undefined })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
{triggerUsesFolderPath(item.type) ? (
|
||||||
|
<div className="min-w-[120px] flex-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Dossier Drive (optionnel)</Label>
|
||||||
|
<AutomationSuggestInput
|
||||||
|
kind={triggerValueSuggestionKind('folder_path')}
|
||||||
|
placeholder="/Documents"
|
||||||
|
value={item.folder_path ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(value) => onChange({ ...item, folder_path: value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{triggerUsesContactLabel(item.type) ? (
|
||||||
|
<div className="min-w-[120px] flex-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Libellé contact (optionnel)</Label>
|
||||||
|
<AutomationSuggestInput
|
||||||
|
kind={triggerValueSuggestionKind('contact_label')}
|
||||||
|
placeholder="Libellé"
|
||||||
|
value={item.contact_label ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(value) => onChange({ ...item, contact_label: value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function AutomationSettingsSection() {
|
|||||||
<>
|
<>
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title="Automatisations"
|
title="Automatisations"
|
||||||
description="Règles graphiques de tri, webhooks, fonctions réutilisables et variables d'exécution."
|
description="Règles et webhooks pour les événements mail, Drive et contacts — conditions et actions adaptées au déclencheur."
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="rules">
|
<Tabs defaultValue="rules">
|
||||||
<TabsList className="flex h-auto flex-wrap">
|
<TabsList className="flex h-auto flex-wrap">
|
||||||
|
|||||||
@ -120,16 +120,35 @@ export function useMailWebhooks() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateMailWebhook() {
|
export type MailWebhookPayload = {
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: {
|
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
method?: string
|
method?: string
|
||||||
body_template?: string
|
body_template?: string
|
||||||
}) => apiClient.post<{ id: string }>('/mail/webhooks', payload),
|
event_types?: string[]
|
||||||
|
mail_scope?: ApiWebhook['mail_scope']
|
||||||
|
drive_scope?: ApiWebhook['drive_scope']
|
||||||
|
contacts_scope?: ApiWebhook['contacts_scope']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMailWebhook() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: MailWebhookPayload) =>
|
||||||
|
apiClient.post<{ id: string }>('/mail/webhooks', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mail-webhooks'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMailWebhook() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ webhookId, ...payload }: MailWebhookPayload & { webhookId: string }) =>
|
||||||
|
apiClient.put(`/mail/webhooks/${webhookId}`, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['mail-webhooks'] })
|
queryClient.invalidateQueries({ queryKey: ['mail-webhooks'] })
|
||||||
},
|
},
|
||||||
|
|||||||
@ -189,6 +189,11 @@ export interface ApiRule {
|
|||||||
account_id?: string
|
account_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiWebhookContactsScope {
|
||||||
|
all_books: boolean
|
||||||
|
book_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiWebhook {
|
export interface ApiWebhook {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -198,6 +203,10 @@ export interface ApiWebhook {
|
|||||||
body_template?: string
|
body_template?: string
|
||||||
max_retries?: number
|
max_retries?: number
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
|
event_types?: string[]
|
||||||
|
mail_scope?: ApiTokenMailScope
|
||||||
|
drive_scope?: ApiTokenDriveScope
|
||||||
|
contacts_scope?: ApiWebhookContactsScope
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiTokenPermissionGrant {
|
export interface ApiTokenPermissionGrant {
|
||||||
|
|||||||
@ -20,28 +20,49 @@ export const ATTACHMENT_CONDITION_OPERATORS: { value: ConditionOperator; label:
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function isStringConditionField(field: ConditionField): boolean {
|
export function isStringConditionField(field: ConditionField): boolean {
|
||||||
return field === 'from' || field === 'to' || field === 'subject' || field === 'body'
|
return (
|
||||||
|
field === 'from' ||
|
||||||
|
field === 'to' ||
|
||||||
|
field === 'subject' ||
|
||||||
|
field === 'body' ||
|
||||||
|
field === 'drive_file_name' ||
|
||||||
|
field === 'drive_file_path' ||
|
||||||
|
field === 'drive_mime_type' ||
|
||||||
|
field === 'contact_name' ||
|
||||||
|
field === 'contact_email' ||
|
||||||
|
field === 'contact_phone' ||
|
||||||
|
field === 'contact_org'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLabelConditionField(field: ConditionField): boolean {
|
export function isLabelConditionField(field: ConditionField): boolean {
|
||||||
return field === 'label'
|
return field === 'label' || field === 'contact_label'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBooleanConditionField(field: ConditionField): boolean {
|
||||||
|
return field === 'has_attachment' || field === 'drive_is_folder'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumericConditionField(field: ConditionField): boolean {
|
||||||
|
return field === 'drive_file_size'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function operatorsForConditionField(field: ConditionField): ConditionOperator[] {
|
export function operatorsForConditionField(field: ConditionField): ConditionOperator[] {
|
||||||
if (field === 'label') return ['has', 'not_has']
|
if (isLabelConditionField(field)) return ['has', 'not_has']
|
||||||
if (field === 'has_attachment') return ['equals']
|
if (isBooleanConditionField(field)) return ['equals']
|
||||||
|
if (isNumericConditionField(field)) return ['equals', 'contains']
|
||||||
return STRING_CONDITION_OPERATORS.map((o) => o.value)
|
return STRING_CONDITION_OPERATORS.map((o) => o.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function operatorOptionsForConditionField(field: ConditionField) {
|
export function operatorOptionsForConditionField(field: ConditionField) {
|
||||||
if (field === 'label') return LABEL_CONDITION_OPERATORS
|
if (isLabelConditionField(field)) return LABEL_CONDITION_OPERATORS
|
||||||
if (field === 'has_attachment') return ATTACHMENT_CONDITION_OPERATORS
|
if (isBooleanConditionField(field) || isNumericConditionField(field)) return ATTACHMENT_CONDITION_OPERATORS
|
||||||
return STRING_CONDITION_OPERATORS
|
return STRING_CONDITION_OPERATORS
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultOperatorForField(field: ConditionField): ConditionOperator {
|
export function defaultOperatorForField(field: ConditionField): ConditionOperator {
|
||||||
if (field === 'label') return 'has'
|
if (isLabelConditionField(field)) return 'has'
|
||||||
if (field === 'has_attachment') return 'equals'
|
if (isBooleanConditionField(field) || isNumericConditionField(field)) return 'equals'
|
||||||
return 'contains'
|
return 'contains'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,28 +71,41 @@ export function formatConditionSummary(data: {
|
|||||||
operator: ConditionOperator
|
operator: ConditionOperator
|
||||||
value: string
|
value: string
|
||||||
}, fieldLabel: (f: string) => string, opLabel: (o: string) => string): string {
|
}, fieldLabel: (f: string) => string, opLabel: (o: string) => string): string {
|
||||||
if (data.field === 'label') {
|
if (isLabelConditionField(data.field)) {
|
||||||
const prefix = data.operator === 'not_has' ? 'Sans libellé' : 'Libellé'
|
const prefix = data.operator === 'not_has' ? 'Sans libellé' : 'Libellé'
|
||||||
return `${prefix} « ${data.value || '…'} »`
|
return `${prefix} « ${data.value || '…'} »`
|
||||||
}
|
}
|
||||||
|
if (isBooleanConditionField(data.field)) {
|
||||||
|
return `${fieldLabel(data.field)} = ${data.value === 'false' ? 'non' : 'oui'}`
|
||||||
|
}
|
||||||
return `${fieldLabel(data.field)} ${opLabel(data.operator)} « ${data.value || '…'} »`
|
return `${fieldLabel(data.field)} ${opLabel(data.operator)} « ${data.value || '…'} »`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutomationSuggestionKind = 'label' | 'folder' | 'folder_id' | 'email' | 'webhook' | 'none'
|
export type AutomationSuggestionKind =
|
||||||
|
| 'label'
|
||||||
|
| 'folder'
|
||||||
|
| 'folder_id'
|
||||||
|
| 'drive_path'
|
||||||
|
| 'mime_type'
|
||||||
|
| 'contact_label'
|
||||||
|
| 'email'
|
||||||
|
| 'webhook'
|
||||||
|
| 'none'
|
||||||
|
|
||||||
export function conditionValueSuggestionKind(
|
export function conditionValueSuggestionKind(
|
||||||
field: ConditionField,
|
field: ConditionField,
|
||||||
operator: ConditionOperator
|
_operator: ConditionOperator
|
||||||
): AutomationSuggestionKind {
|
): AutomationSuggestionKind {
|
||||||
if (field === 'label') return 'label'
|
if (field === 'label') return 'label'
|
||||||
if (field === 'from' || field === 'to') return 'email'
|
if (field === 'contact_label') return 'contact_label'
|
||||||
|
if (field === 'from' || field === 'to' || field === 'contact_email') return 'email'
|
||||||
|
if (field === 'drive_file_path') return 'drive_path'
|
||||||
|
if (field === 'drive_mime_type') return 'mime_type'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function switchFieldSuggestionKind(field: string): AutomationSuggestionKind {
|
export function switchFieldSuggestionKind(field: string): AutomationSuggestionKind {
|
||||||
if (field === 'label') return 'label'
|
return conditionValueSuggestionKind(field as ConditionField, 'contains')
|
||||||
if (field === 'from' || field === 'to') return 'email'
|
|
||||||
return 'none'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function actionValueSuggestionKind(type: ActionType): AutomationSuggestionKind {
|
export function actionValueSuggestionKind(type: ActionType): AutomationSuggestionKind {
|
||||||
@ -83,7 +117,14 @@ export function actionValueSuggestionKind(type: ActionType): AutomationSuggestio
|
|||||||
return 'folder'
|
return 'folder'
|
||||||
case 'forward':
|
case 'forward':
|
||||||
case 'send_mail':
|
case 'send_mail':
|
||||||
|
case 'drive_share':
|
||||||
return 'email'
|
return 'email'
|
||||||
|
case 'drive_move':
|
||||||
|
case 'drive_copy':
|
||||||
|
return 'drive_path'
|
||||||
|
case 'contact_add_label':
|
||||||
|
case 'contact_remove_label':
|
||||||
|
return 'contact_label'
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return 'webhook'
|
return 'webhook'
|
||||||
default:
|
default:
|
||||||
@ -92,10 +133,11 @@ export function actionValueSuggestionKind(type: ActionType): AutomationSuggestio
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function triggerValueSuggestionKind(
|
export function triggerValueSuggestionKind(
|
||||||
type: 'message_received' | 'label_added' | 'label_removed',
|
field: 'folder_id' | 'label' | 'folder_path' | 'contact_label'
|
||||||
field: 'folder_id' | 'label'
|
|
||||||
): AutomationSuggestionKind {
|
): AutomationSuggestionKind {
|
||||||
if (field === 'label') return 'label'
|
if (field === 'label') return 'label'
|
||||||
if (field === 'folder_id') return 'folder_id'
|
if (field === 'folder_id') return 'folder_id'
|
||||||
|
if (field === 'folder_path') return 'drive_path'
|
||||||
|
if (field === 'contact_label') return 'contact_label'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
defaultActionForDomain,
|
||||||
|
defaultConditionFieldForDomain,
|
||||||
|
defaultTriggerForDomain,
|
||||||
|
} from './domains'
|
||||||
|
import type { AutomationDomain } from './domains'
|
||||||
import type {
|
import type {
|
||||||
ActionItem,
|
ActionItem,
|
||||||
AutomationWorkflow,
|
AutomationWorkflow,
|
||||||
@ -13,11 +19,16 @@ export function nextNodeId(prefix: string) {
|
|||||||
return `${prefix}-${Date.now()}-${nodeCounter}`
|
return `${prefix}-${Date.now()}-${nodeCounter}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultWorkflow(kind: 'rule' | 'function' = 'rule'): AutomationWorkflow {
|
export function createDefaultWorkflow(
|
||||||
|
kind: 'rule' | 'function' = 'rule',
|
||||||
|
domain: AutomationDomain = 'mail'
|
||||||
|
): AutomationWorkflow {
|
||||||
const startId = nextNodeId('start')
|
const startId = nextNodeId('start')
|
||||||
const condId = nextNodeId('cond')
|
const condId = nextNodeId('cond')
|
||||||
const actionsId = nextNodeId('actions')
|
const actionsId = nextNodeId('actions')
|
||||||
const endId = nextNodeId('end')
|
const endId = nextNodeId('end')
|
||||||
|
const conditionField = defaultConditionFieldForDomain(domain)
|
||||||
|
const defaultAction = defaultActionForDomain(domain)
|
||||||
|
|
||||||
const nodes: WorkflowNodeDef[] = [
|
const nodes: WorkflowNodeDef[] = [
|
||||||
{ id: startId, type: 'start', position: { x: 80, y: 200 }, data: {} },
|
{ id: startId, type: 'start', position: { x: 80, y: 200 }, data: {} },
|
||||||
@ -25,13 +36,13 @@ export function createDefaultWorkflow(kind: 'rule' | 'function' = 'rule'): Autom
|
|||||||
id: condId,
|
id: condId,
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
position: { x: 280, y: 180 },
|
position: { x: 280, y: 180 },
|
||||||
data: { field: 'subject', operator: 'contains', value: '' },
|
data: { field: conditionField, operator: 'contains', value: '' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: actionsId,
|
id: actionsId,
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
position: { x: 520, y: 160 },
|
position: { x: 520, y: 160 },
|
||||||
data: { actions: [{ type: 'label', value: '' }] },
|
data: { actions: [{ type: defaultAction, value: '' }] },
|
||||||
},
|
},
|
||||||
{ id: endId, type: 'end', position: { x: 760, y: 200 }, data: {} },
|
{ id: endId, type: 'end', position: { x: 760, y: 200 }, data: {} },
|
||||||
]
|
]
|
||||||
@ -51,7 +62,7 @@ export function createDefaultWorkflow(kind: 'rule' | 'function' = 'rule'): Autom
|
|||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
operator: 'and',
|
operator: 'and',
|
||||||
items: [{ type: 'message_received' }],
|
items: [defaultTriggerForDomain(domain)],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -73,8 +84,24 @@ export function createDefaultRuleEditorState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyAction(): ActionItem {
|
export function createEmptyAction(domain: AutomationDomain = 'mail'): ActionItem {
|
||||||
return { type: 'label', value: '' }
|
return { type: defaultActionForDomain(domain), value: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIMULATION_DRIVE_FILE = {
|
||||||
|
file_name: 'document.pdf',
|
||||||
|
file_path: '/Documents/document.pdf',
|
||||||
|
mime_type: 'application/pdf',
|
||||||
|
file_size: 102400,
|
||||||
|
is_folder: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIMULATION_CONTACT = {
|
||||||
|
name: 'Alice Example',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
phone: '+33 6 00 00 00 00',
|
||||||
|
org: 'Example Corp',
|
||||||
|
labels: [] as string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SIMULATION_MESSAGE = {
|
export const DEFAULT_SIMULATION_MESSAGE = {
|
||||||
|
|||||||
218
lib/mail-automation/domains.ts
Normal file
218
lib/mail-automation/domains.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import type {
|
||||||
|
ActionType,
|
||||||
|
AutomationTrigger,
|
||||||
|
ConditionField,
|
||||||
|
TriggerOrGroup,
|
||||||
|
TriggerType,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export type AutomationDomain = 'mail' | 'drive' | 'contacts'
|
||||||
|
|
||||||
|
export const AUTOMATION_DOMAIN_LABELS: Record<AutomationDomain, string> = {
|
||||||
|
mail: 'Mail',
|
||||||
|
drive: 'Drive',
|
||||||
|
contacts: 'Contacts',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOMAIN_TRIGGER_TYPES: Record<AutomationDomain, TriggerType[]> = {
|
||||||
|
mail: ['message_received', 'label_added', 'label_removed'],
|
||||||
|
drive: [
|
||||||
|
'drive_file_created',
|
||||||
|
'drive_file_updated',
|
||||||
|
'drive_file_deleted',
|
||||||
|
'drive_file_moved',
|
||||||
|
'drive_share_updated',
|
||||||
|
],
|
||||||
|
contacts: ['contact_created', 'contact_updated', 'contact_deleted'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRIGGER_LABELS: Record<TriggerType, string> = {
|
||||||
|
message_received: 'Mail reçu',
|
||||||
|
label_added: 'Libellé ajouté',
|
||||||
|
label_removed: 'Libellé retiré',
|
||||||
|
drive_file_created: 'Fichier ajouté',
|
||||||
|
drive_file_updated: 'Fichier modifié',
|
||||||
|
drive_file_deleted: 'Fichier supprimé',
|
||||||
|
drive_file_moved: 'Fichier déplacé',
|
||||||
|
drive_share_updated: 'Partage modifié',
|
||||||
|
contact_created: 'Contact créé',
|
||||||
|
contact_updated: 'Contact modifié',
|
||||||
|
contact_deleted: 'Contact supprimé',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOMAIN_CONDITION_FIELDS: Record<AutomationDomain, ConditionField[]> = {
|
||||||
|
mail: ['from', 'to', 'subject', 'body', 'has_attachment', 'label'],
|
||||||
|
drive: ['drive_file_name', 'drive_file_path', 'drive_mime_type', 'drive_file_size', 'drive_is_folder'],
|
||||||
|
contacts: ['contact_name', 'contact_email', 'contact_phone', 'contact_org', 'contact_label'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONDITION_FIELD_LABELS: Record<ConditionField, string> = {
|
||||||
|
from: 'Expéditeur',
|
||||||
|
to: 'Destinataire',
|
||||||
|
subject: 'Sujet',
|
||||||
|
body: 'Corps',
|
||||||
|
has_attachment: 'Pièce jointe',
|
||||||
|
label: 'Libellé du message',
|
||||||
|
drive_file_name: 'Nom du fichier',
|
||||||
|
drive_file_path: 'Chemin',
|
||||||
|
drive_mime_type: 'Type MIME',
|
||||||
|
drive_file_size: 'Taille (octets)',
|
||||||
|
drive_is_folder: 'Dossier',
|
||||||
|
contact_name: 'Nom du contact',
|
||||||
|
contact_email: 'E-mail du contact',
|
||||||
|
contact_phone: 'Téléphone',
|
||||||
|
contact_org: 'Organisation',
|
||||||
|
contact_label: 'Libellé du contact',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNIVERSAL_ACTION_TYPES: ActionType[] = ['webhook', 'notify']
|
||||||
|
|
||||||
|
export const DOMAIN_ACTION_TYPES: Record<AutomationDomain, ActionType[]> = {
|
||||||
|
mail: [
|
||||||
|
'label',
|
||||||
|
'remove_label',
|
||||||
|
'move',
|
||||||
|
'archive',
|
||||||
|
'delete',
|
||||||
|
'mark_read',
|
||||||
|
'mark_important',
|
||||||
|
'mark_spam',
|
||||||
|
'star',
|
||||||
|
'reply',
|
||||||
|
'send_mail',
|
||||||
|
'forward',
|
||||||
|
],
|
||||||
|
drive: ['drive_move', 'drive_rename', 'drive_delete', 'drive_share', 'drive_copy'],
|
||||||
|
contacts: ['contact_add_label', 'contact_remove_label', 'contact_delete'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTION_TYPE_META: Record<
|
||||||
|
ActionType,
|
||||||
|
{ label: string; needsValue: boolean; placeholder?: string; domain?: AutomationDomain | 'universal' }
|
||||||
|
> = {
|
||||||
|
label: { label: 'Ajouter libellé', needsValue: true, placeholder: 'Nom du libellé', domain: 'mail' },
|
||||||
|
remove_label: { label: 'Retirer libellé', needsValue: true, placeholder: 'Nom du libellé', domain: 'mail' },
|
||||||
|
move: { label: 'Déplacer vers dossier', needsValue: true, placeholder: 'Nom du dossier', domain: 'mail' },
|
||||||
|
archive: { label: 'Archiver', needsValue: false, domain: 'mail' },
|
||||||
|
delete: { label: 'Supprimer', needsValue: false, domain: 'mail' },
|
||||||
|
mark_read: { label: 'Marquer lu', needsValue: false, domain: 'mail' },
|
||||||
|
mark_important: { label: 'Marquer important', needsValue: false, domain: 'mail' },
|
||||||
|
mark_spam: { label: 'Marquer spam', needsValue: false, domain: 'mail' },
|
||||||
|
star: { label: 'Suivi / étoile', needsValue: false, domain: 'mail' },
|
||||||
|
reply: { label: 'Répondre', needsValue: true, placeholder: 'Corps de la réponse', domain: 'mail' },
|
||||||
|
send_mail: { label: 'Envoyer un mail', needsValue: true, placeholder: 'dest@example.com', domain: 'mail' },
|
||||||
|
forward: { label: 'Transférer', needsValue: true, placeholder: 'dest@example.com', domain: 'mail' },
|
||||||
|
drive_move: { label: 'Déplacer le fichier', needsValue: true, placeholder: '/Dossier cible', domain: 'drive' },
|
||||||
|
drive_rename: { label: 'Renommer le fichier', needsValue: true, placeholder: 'nouveau-nom.pdf', domain: 'drive' },
|
||||||
|
drive_delete: { label: 'Supprimer le fichier', needsValue: false, domain: 'drive' },
|
||||||
|
drive_share: { label: 'Partager', needsValue: true, placeholder: 'email@example.com', domain: 'drive' },
|
||||||
|
drive_copy: { label: 'Copier vers', needsValue: true, placeholder: '/Dossier cible', domain: 'drive' },
|
||||||
|
contact_add_label: { label: 'Ajouter libellé contact', needsValue: true, placeholder: 'Libellé', domain: 'contacts' },
|
||||||
|
contact_remove_label: { label: 'Retirer libellé contact', needsValue: true, placeholder: 'Libellé', domain: 'contacts' },
|
||||||
|
contact_delete: { label: 'Supprimer le contact', needsValue: false, domain: 'contacts' },
|
||||||
|
webhook: { label: 'Webhook', needsValue: true, placeholder: 'ID du template webhook', domain: 'universal' },
|
||||||
|
notify: { label: 'Notification', needsValue: true, placeholder: 'Message notification', domain: 'universal' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDomain(type: TriggerType): AutomationDomain {
|
||||||
|
if (type.startsWith('drive_')) return 'drive'
|
||||||
|
if (type.startsWith('contact_')) return 'contacts'
|
||||||
|
return 'mail'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferDomainsFromTriggers(triggers: TriggerOrGroup): AutomationDomain[] {
|
||||||
|
const domains = new Set<AutomationDomain>()
|
||||||
|
for (const group of triggers.groups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
domains.add(triggerDomain(item.type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (domains.size === 0) domains.add('mail')
|
||||||
|
return [...domains]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primaryDomainFromTriggers(triggers: TriggerOrGroup): AutomationDomain {
|
||||||
|
const domains = inferDomainsFromTriggers(triggers)
|
||||||
|
return domains[0] ?? 'mail'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conditionFieldsForDomains(domains: AutomationDomain[]): { value: ConditionField; label: string; domain: AutomationDomain }[] {
|
||||||
|
const out: { value: ConditionField; label: string; domain: AutomationDomain }[] = []
|
||||||
|
for (const domain of domains) {
|
||||||
|
for (const field of DOMAIN_CONDITION_FIELDS[domain]) {
|
||||||
|
out.push({ value: field, label: CONDITION_FIELD_LABELS[field], domain })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionTypesForDomains(domains: AutomationDomain[]): {
|
||||||
|
value: ActionType
|
||||||
|
label: string
|
||||||
|
needsValue: boolean
|
||||||
|
placeholder?: string
|
||||||
|
domain: AutomationDomain | 'universal'
|
||||||
|
}[] {
|
||||||
|
const seen = new Set<ActionType>()
|
||||||
|
const out: ReturnType<typeof actionTypesForDomains> = []
|
||||||
|
|
||||||
|
function push(type: ActionType) {
|
||||||
|
if (seen.has(type)) return
|
||||||
|
seen.add(type)
|
||||||
|
const meta = ACTION_TYPE_META[type]
|
||||||
|
out.push({
|
||||||
|
value: type,
|
||||||
|
label: meta.label,
|
||||||
|
needsValue: meta.needsValue,
|
||||||
|
placeholder: meta.placeholder,
|
||||||
|
domain: meta.domain ?? 'universal',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of UNIVERSAL_ACTION_TYPES) push(type)
|
||||||
|
for (const domain of domains) {
|
||||||
|
for (const type of DOMAIN_ACTION_TYPES[domain]) push(type)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultConditionFieldForDomain(domain: AutomationDomain): ConditionField {
|
||||||
|
return DOMAIN_CONDITION_FIELDS[domain][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultActionForDomain(domain: AutomationDomain): ActionType {
|
||||||
|
if (domain === 'drive') return 'drive_move'
|
||||||
|
if (domain === 'contacts') return 'contact_add_label'
|
||||||
|
return 'label'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultTriggerForDomain(domain: AutomationDomain): AutomationTrigger {
|
||||||
|
const type = DOMAIN_TRIGGER_TYPES[domain][0]
|
||||||
|
if (domain === 'mail') return { type }
|
||||||
|
if (domain === 'drive') return { type, folder_path: undefined }
|
||||||
|
return { type, contact_label: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerUsesFolderPath(type: TriggerType): boolean {
|
||||||
|
return type.startsWith('drive_')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerUsesMailFolder(type: TriggerType): boolean {
|
||||||
|
return type === 'message_received'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerUsesMailLabel(type: TriggerType): boolean {
|
||||||
|
return type === 'label_added' || type === 'label_removed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerUsesContactLabel(type: TriggerType): boolean {
|
||||||
|
return type.startsWith('contact_')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conditionFieldLabel(field: string): string {
|
||||||
|
return CONDITION_FIELD_LABELS[field as ConditionField] ?? field
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionTypeLabel(type: string): string {
|
||||||
|
return ACTION_TYPE_META[type as ActionType]?.label ?? type
|
||||||
|
}
|
||||||
@ -1,4 +1,15 @@
|
|||||||
import type { ActionType, ConditionField, ConditionOperator, WorkflowNodeType } from './types'
|
import type { ActionType, ConditionField, ConditionOperator, WorkflowNodeType } from './types'
|
||||||
|
import {
|
||||||
|
ACTION_TYPE_META,
|
||||||
|
CONDITION_FIELD_LABELS,
|
||||||
|
TRIGGER_LABELS,
|
||||||
|
actionTypesForDomains,
|
||||||
|
conditionFieldsForDomains,
|
||||||
|
inferDomainsFromTriggers,
|
||||||
|
primaryDomainFromTriggers,
|
||||||
|
} from './domains'
|
||||||
|
import type { AutomationDomain } from './domains'
|
||||||
|
import type { TriggerOrGroup } from './types'
|
||||||
import {
|
import {
|
||||||
LABEL_CONDITION_OPERATORS,
|
LABEL_CONDITION_OPERATORS,
|
||||||
STRING_CONDITION_OPERATORS,
|
STRING_CONDITION_OPERATORS,
|
||||||
@ -6,12 +17,7 @@ import {
|
|||||||
} from './condition-helpers'
|
} from './condition-helpers'
|
||||||
|
|
||||||
export { LABEL_CONDITION_OPERATORS, STRING_CONDITION_OPERATORS, operatorOptionsForConditionField }
|
export { LABEL_CONDITION_OPERATORS, STRING_CONDITION_OPERATORS, operatorOptionsForConditionField }
|
||||||
|
export { TRIGGER_LABELS, CONDITION_FIELD_LABELS, ACTION_TYPE_META }
|
||||||
export const TRIGGER_LABELS: Record<string, string> = {
|
|
||||||
message_received: 'Mail reçu',
|
|
||||||
label_added: 'Libellé ajouté',
|
|
||||||
label_removed: 'Libellé retiré',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<WorkflowNodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<WorkflowNodeType, string> = {
|
||||||
start: 'Début',
|
start: 'Début',
|
||||||
@ -28,7 +34,7 @@ export const NODE_TYPE_LABELS: Record<WorkflowNodeType, string> = {
|
|||||||
|
|
||||||
export const NODE_TYPE_DESCRIPTIONS: Record<WorkflowNodeType, string> = {
|
export const NODE_TYPE_DESCRIPTIONS: Record<WorkflowNodeType, string> = {
|
||||||
start: "Point d'entrée du flux",
|
start: "Point d'entrée du flux",
|
||||||
condition: 'If / else sur métadonnées mail ou libellés',
|
condition: 'If / else sur métadonnées de l’événement déclencheur',
|
||||||
label_check: 'Ancien nœud libellé (legacy)',
|
label_check: 'Ancien nœud libellé (legacy)',
|
||||||
switch: 'Branchement multi-cas',
|
switch: 'Branchement multi-cas',
|
||||||
llm_check: 'Décision via prompt LLM',
|
llm_check: 'Décision via prompt LLM',
|
||||||
@ -39,38 +45,36 @@ export const NODE_TYPE_DESCRIPTIONS: Record<WorkflowNodeType, string> = {
|
|||||||
end: 'Termine le flux',
|
end: 'Termine le flux',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_FIELDS: { value: ConditionField; label: string }[] = [
|
/** @deprecated Préférer conditionFieldsForDomains(activeDomains) */
|
||||||
{ value: 'from', label: 'Expéditeur' },
|
export const CONDITION_FIELDS: { value: ConditionField; label: string }[] = conditionFieldsForDomains([
|
||||||
{ value: 'to', label: 'Destinataire' },
|
'mail',
|
||||||
{ value: 'subject', label: 'Sujet' },
|
'drive',
|
||||||
{ value: 'body', label: 'Corps' },
|
'contacts',
|
||||||
{ value: 'has_attachment', label: 'Pièce jointe' },
|
]).map(({ value, label }) => ({ value, label }))
|
||||||
{ value: 'label', label: 'Libellé du message' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const CONDITION_OPERATORS = STRING_CONDITION_OPERATORS
|
export const CONDITION_OPERATORS = STRING_CONDITION_OPERATORS
|
||||||
|
|
||||||
|
/** @deprecated Préférer actionTypesForDomains(activeDomains) */
|
||||||
export const ACTION_TYPES: {
|
export const ACTION_TYPES: {
|
||||||
value: ActionType
|
value: ActionType
|
||||||
label: string
|
label: string
|
||||||
needsValue: boolean
|
needsValue: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}[] = [
|
}[] = actionTypesForDomains(['mail', 'drive', 'contacts']).map(
|
||||||
{ value: 'label', label: 'Ajouter libellé', needsValue: true, placeholder: 'Nom du libellé' },
|
({ value, label, needsValue, placeholder }) => ({ value, label, needsValue, placeholder })
|
||||||
{ value: 'remove_label', label: 'Retirer libellé', needsValue: true, placeholder: 'Nom du libellé' },
|
)
|
||||||
{ value: 'move', label: 'Déplacer vers dossier', needsValue: true, placeholder: 'Nom du dossier' },
|
|
||||||
{ value: 'archive', label: 'Archiver', needsValue: false },
|
export function conditionFieldsForWorkflow(triggers: TriggerOrGroup) {
|
||||||
{ value: 'delete', label: 'Supprimer', needsValue: false },
|
return conditionFieldsForDomains(inferDomainsFromTriggers(triggers))
|
||||||
{ value: 'mark_read', label: 'Marquer lu', needsValue: false },
|
}
|
||||||
{ value: 'mark_important', label: 'Marquer important', needsValue: false },
|
|
||||||
{ value: 'mark_spam', label: 'Marquer spam', needsValue: false },
|
export function actionTypesForWorkflow(triggers: TriggerOrGroup) {
|
||||||
{ value: 'star', label: 'Suivi / étoile', needsValue: false },
|
return actionTypesForDomains(inferDomainsFromTriggers(triggers))
|
||||||
{ value: 'webhook', label: 'Webhook', needsValue: true, placeholder: 'ID du template webhook' },
|
}
|
||||||
{ value: 'notify', label: 'Notification', needsValue: true, placeholder: 'Message notification' },
|
|
||||||
{ value: 'reply', label: 'Répondre', needsValue: true, placeholder: 'Corps de la réponse' },
|
export function primaryDomainForWorkflow(triggers: TriggerOrGroup): AutomationDomain {
|
||||||
{ value: 'send_mail', label: 'Envoyer un mail', needsValue: true, placeholder: 'dest@example.com' },
|
return primaryDomainFromTriggers(triggers)
|
||||||
{ value: 'forward', label: 'Transférer', needsValue: true, placeholder: 'dest@example.com' },
|
}
|
||||||
]
|
|
||||||
|
|
||||||
export const PALETTE_NODE_TYPES: WorkflowNodeType[] = [
|
export const PALETTE_NODE_TYPES: WorkflowNodeType[] = [
|
||||||
'condition',
|
'condition',
|
||||||
|
|||||||
@ -2,13 +2,28 @@
|
|||||||
|
|
||||||
export type RuleKind = 'rule' | 'function'
|
export type RuleKind = 'rule' | 'function'
|
||||||
|
|
||||||
export type TriggerType = 'message_received' | 'label_added' | 'label_removed'
|
export type TriggerType =
|
||||||
|
| 'message_received'
|
||||||
|
| 'label_added'
|
||||||
|
| 'label_removed'
|
||||||
|
| 'drive_file_created'
|
||||||
|
| 'drive_file_updated'
|
||||||
|
| 'drive_file_deleted'
|
||||||
|
| 'drive_file_moved'
|
||||||
|
| 'drive_share_updated'
|
||||||
|
| 'contact_created'
|
||||||
|
| 'contact_updated'
|
||||||
|
| 'contact_deleted'
|
||||||
|
|
||||||
export interface AutomationTrigger {
|
export interface AutomationTrigger {
|
||||||
type: TriggerType
|
type: TriggerType
|
||||||
folder_id?: string
|
folder_id?: string
|
||||||
label?: string
|
label?: string
|
||||||
account_id?: string
|
account_id?: string
|
||||||
|
/** Chemin Drive (filtre optionnel sur les déclencheurs Drive) */
|
||||||
|
folder_path?: string
|
||||||
|
/** Libellé contact (filtre optionnel sur les déclencheurs Contacts) */
|
||||||
|
contact_label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TriggerAndGroup {
|
export interface TriggerAndGroup {
|
||||||
@ -46,6 +61,16 @@ export type ConditionField =
|
|||||||
| 'body'
|
| 'body'
|
||||||
| 'has_attachment'
|
| 'has_attachment'
|
||||||
| 'label'
|
| 'label'
|
||||||
|
| 'drive_file_name'
|
||||||
|
| 'drive_file_path'
|
||||||
|
| 'drive_mime_type'
|
||||||
|
| 'drive_file_size'
|
||||||
|
| 'drive_is_folder'
|
||||||
|
| 'contact_name'
|
||||||
|
| 'contact_email'
|
||||||
|
| 'contact_phone'
|
||||||
|
| 'contact_org'
|
||||||
|
| 'contact_label'
|
||||||
|
|
||||||
export type ConditionOperator =
|
export type ConditionOperator =
|
||||||
| 'contains'
|
| 'contains'
|
||||||
@ -101,6 +126,14 @@ export type ActionType =
|
|||||||
| 'reply'
|
| 'reply'
|
||||||
| 'send_mail'
|
| 'send_mail'
|
||||||
| 'forward'
|
| 'forward'
|
||||||
|
| 'drive_move'
|
||||||
|
| 'drive_rename'
|
||||||
|
| 'drive_delete'
|
||||||
|
| 'drive_share'
|
||||||
|
| 'drive_copy'
|
||||||
|
| 'contact_add_label'
|
||||||
|
| 'contact_remove_label'
|
||||||
|
| 'contact_delete'
|
||||||
|
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
type: ActionType
|
type: ActionType
|
||||||
@ -162,6 +195,22 @@ export interface RuleSimulationMessage {
|
|||||||
labels?: string[]
|
labels?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuleSimulationDriveFile {
|
||||||
|
file_name: string
|
||||||
|
file_path: string
|
||||||
|
mime_type: string
|
||||||
|
file_size: number
|
||||||
|
is_folder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleSimulationContact {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
org: string
|
||||||
|
labels?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleSimulationStep {
|
export interface RuleSimulationStep {
|
||||||
node_id: string
|
node_id: string
|
||||||
node_type: string
|
node_type: string
|
||||||
|
|||||||
@ -108,6 +108,22 @@ export function useAutomationSuggestions() {
|
|||||||
return emailSuggestions
|
return emailSuggestions
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return webhookSuggestions
|
return webhookSuggestions
|
||||||
|
case 'drive_path':
|
||||||
|
return [
|
||||||
|
{ value: '/', label: 'Racine Drive' },
|
||||||
|
{ value: '/Documents', label: 'Documents' },
|
||||||
|
{ value: '/Photos', label: 'Photos' },
|
||||||
|
]
|
||||||
|
case 'mime_type':
|
||||||
|
return [
|
||||||
|
{ value: 'application/pdf', label: 'PDF' },
|
||||||
|
{ value: 'image/jpeg', label: 'JPEG' },
|
||||||
|
{ value: 'image/png', label: 'PNG' },
|
||||||
|
{ value: 'text/plain', label: 'Texte' },
|
||||||
|
{ value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'Word' },
|
||||||
|
]
|
||||||
|
case 'contact_label':
|
||||||
|
return labelSuggestions
|
||||||
default:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
81
lib/mail-automation/webhook-config.ts
Normal file
81
lib/mail-automation/webhook-config.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import type { AutomationDomain } from './domains'
|
||||||
|
import { DOMAIN_TRIGGER_TYPES, TRIGGER_LABELS } from './domains'
|
||||||
|
import type { TriggerType } from './types'
|
||||||
|
import {
|
||||||
|
defaultDriveScope,
|
||||||
|
defaultMailScope,
|
||||||
|
type ApiTokenDriveScope,
|
||||||
|
type ApiTokenMailScope,
|
||||||
|
} from './api-token-permissions'
|
||||||
|
|
||||||
|
export interface WebhookContactsScope {
|
||||||
|
all_books: boolean
|
||||||
|
book_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultContactsScope(): WebhookContactsScope {
|
||||||
|
return { all_books: true, book_ids: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebhookEventOption = {
|
||||||
|
value: TriggerType
|
||||||
|
label: string
|
||||||
|
domain: AutomationDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEBHOOK_EVENT_OPTIONS: WebhookEventOption[] = (
|
||||||
|
['mail', 'drive', 'contacts'] as AutomationDomain[]
|
||||||
|
).flatMap((domain) =>
|
||||||
|
DOMAIN_TRIGGER_TYPES[domain].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: TRIGGER_LABELS[value],
|
||||||
|
domain,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
export function defaultEventTypesForDomain(domain: AutomationDomain): TriggerType[] {
|
||||||
|
return [...DOMAIN_TRIGGER_TYPES[domain]]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventDomainsFromTypes(types: TriggerType[]): AutomationDomain[] {
|
||||||
|
const domains = new Set<AutomationDomain>()
|
||||||
|
for (const type of types) {
|
||||||
|
const opt = WEBHOOK_EVENT_OPTIONS.find((o) => o.value === type)
|
||||||
|
if (opt) domains.add(opt.domain)
|
||||||
|
}
|
||||||
|
return [...domains]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMailWebhookEvents(types: TriggerType[]): boolean {
|
||||||
|
return types.some((t) => DOMAIN_TRIGGER_TYPES.mail.includes(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDriveWebhookEvents(types: TriggerType[]): boolean {
|
||||||
|
return types.some((t) => DOMAIN_TRIGGER_TYPES.drive.includes(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasContactsWebhookEvents(types: TriggerType[]): boolean {
|
||||||
|
return types.some((t) => DOMAIN_TRIGGER_TYPES.contacts.includes(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebhookFormState = {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
template: string
|
||||||
|
eventTypes: TriggerType[]
|
||||||
|
mailScope: ApiTokenMailScope
|
||||||
|
driveScope: ApiTokenDriveScope
|
||||||
|
contactsScope: WebhookContactsScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultWebhookForm(domain: AutomationDomain = 'mail'): WebhookFormState {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
template: '',
|
||||||
|
eventTypes: defaultEventTypesForDomain(domain),
|
||||||
|
mailScope: defaultMailScope(),
|
||||||
|
driveScope: defaultDriveScope(),
|
||||||
|
contactsScope: defaultContactsScope(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,19 +5,49 @@ export type WebhookTemplateVariable = {
|
|||||||
example?: string
|
example?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { AutomationDomain } from './domains'
|
||||||
|
|
||||||
export type WebhookTemplateVariableGroup = {
|
export type WebhookTemplateVariableGroup = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
|
domain: AutomationDomain | 'universal'
|
||||||
variables: WebhookTemplateVariable[]
|
variables: WebhookTemplateVariable[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables interpolées côté backend (`internal/mail/webhooks/executor.go`). */
|
/** Variables interpolées côté backend (`internal/mail/webhooks/executor.go`). */
|
||||||
export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] = [
|
export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] = [
|
||||||
|
{
|
||||||
|
id: "event",
|
||||||
|
label: "Événement",
|
||||||
|
description: "Métadonnées communes à tout type d'automatisation.",
|
||||||
|
domain: "universal",
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
token: "$event.type",
|
||||||
|
label: "Type",
|
||||||
|
description: "Type d'événement déclencheur (ex. message_received, drive_file_created).",
|
||||||
|
example: "drive_file_created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$event.domain",
|
||||||
|
label: "Domaine",
|
||||||
|
description: "Domaine source : mail, drive ou contacts.",
|
||||||
|
example: "drive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$date",
|
||||||
|
label: "Date",
|
||||||
|
description: "Horodatage ISO 8601 de l'événement.",
|
||||||
|
example: "2026-05-22T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "sender",
|
id: "sender",
|
||||||
label: "Expéditeur",
|
label: "Expéditeur",
|
||||||
description: "Identité de l'expéditeur du message déclencheur.",
|
description: "Identité de l'expéditeur du message déclencheur.",
|
||||||
|
domain: "mail",
|
||||||
variables: [
|
variables: [
|
||||||
{
|
{
|
||||||
token: "$sender.name",
|
token: "$sender.name",
|
||||||
@ -37,6 +67,7 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
|||||||
id: "message",
|
id: "message",
|
||||||
label: "Message",
|
label: "Message",
|
||||||
description: "Métadonnées principales du mail.",
|
description: "Métadonnées principales du mail.",
|
||||||
|
domain: "mail",
|
||||||
variables: [
|
variables: [
|
||||||
{
|
{
|
||||||
token: "$subject",
|
token: "$subject",
|
||||||
@ -50,18 +81,13 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
|||||||
description: "Identifiant interne Ultimail du message.",
|
description: "Identifiant interne Ultimail du message.",
|
||||||
example: "msg-123",
|
example: "msg-123",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
token: "$date",
|
|
||||||
label: "Date",
|
|
||||||
description: "Date de réception au format ISO 8601.",
|
|
||||||
example: "2026-05-22T10:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "body",
|
id: "body",
|
||||||
label: "Contenu",
|
label: "Contenu",
|
||||||
description: "Corps du message, en texte ou HTML.",
|
description: "Corps du message, en texte ou HTML.",
|
||||||
|
domain: "mail",
|
||||||
variables: [
|
variables: [
|
||||||
{
|
{
|
||||||
token: "$body.textContent",
|
token: "$body.textContent",
|
||||||
@ -81,6 +107,7 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
|||||||
id: "recipients",
|
id: "recipients",
|
||||||
label: "Destinataires",
|
label: "Destinataires",
|
||||||
description: "Adresses en copie directe (To).",
|
description: "Adresses en copie directe (To).",
|
||||||
|
domain: "mail",
|
||||||
variables: [
|
variables: [
|
||||||
{
|
{
|
||||||
token: "$recipients.to",
|
token: "$recipients.to",
|
||||||
@ -90,8 +117,99 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "drive",
|
||||||
|
label: "Fichier Drive",
|
||||||
|
description: "Métadonnées du fichier ou dossier déclencheur.",
|
||||||
|
domain: "drive",
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
token: "$drive.file_name",
|
||||||
|
label: "Nom",
|
||||||
|
description: "Nom du fichier ou dossier.",
|
||||||
|
example: "rapport.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$drive.file_path",
|
||||||
|
label: "Chemin",
|
||||||
|
description: "Chemin complet dans le Drive.",
|
||||||
|
example: "/Documents/rapport.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$drive.mime_type",
|
||||||
|
label: "Type MIME",
|
||||||
|
description: "Type MIME du fichier.",
|
||||||
|
example: "application/pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$drive.file_size",
|
||||||
|
label: "Taille",
|
||||||
|
description: "Taille en octets.",
|
||||||
|
example: "102400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$drive.is_folder",
|
||||||
|
label: "Dossier",
|
||||||
|
description: "true si l'élément est un dossier.",
|
||||||
|
example: "false",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
label: "Contact",
|
||||||
|
description: "Métadonnées du contact déclencheur.",
|
||||||
|
domain: "contacts",
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
token: "$contact.name",
|
||||||
|
label: "Nom",
|
||||||
|
description: "Nom affiché du contact.",
|
||||||
|
example: "Alice Example",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$contact.email",
|
||||||
|
label: "E-mail",
|
||||||
|
description: "Adresse e-mail principale.",
|
||||||
|
example: "alice@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$contact.phone",
|
||||||
|
label: "Téléphone",
|
||||||
|
description: "Numéro de téléphone principal.",
|
||||||
|
example: "+33 6 00 00 00 00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$contact.org",
|
||||||
|
label: "Organisation",
|
||||||
|
description: "Entreprise ou organisation.",
|
||||||
|
example: "Example Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "$contact.id",
|
||||||
|
label: "Identifiant",
|
||||||
|
description: "Identifiant interne du contact.",
|
||||||
|
example: "contact-42",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WEBHOOK_TEMPLATE_VARIABLES = WEBHOOK_TEMPLATE_VARIABLE_GROUPS.flatMap(
|
export const WEBHOOK_TEMPLATE_VARIABLES = WEBHOOK_TEMPLATE_VARIABLE_GROUPS.flatMap(
|
||||||
(group) => group.variables
|
(group) => group.variables
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export function webhookVariableGroupsForDomains(
|
||||||
|
domains: AutomationDomain[],
|
||||||
|
includeUniversal = true
|
||||||
|
): WebhookTemplateVariableGroup[] {
|
||||||
|
const allowed = new Set<AutomationDomain | 'universal'>(domains)
|
||||||
|
if (includeUniversal) allowed.add('universal')
|
||||||
|
return WEBHOOK_TEMPLATE_VARIABLE_GROUPS.filter((g) => allowed.has(g.domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEBHOOK_DEFAULT_TEMPLATES: Record<AutomationDomain, string> = {
|
||||||
|
mail: '{"text":"Nouveau mail de $sender.name : $subject"}',
|
||||||
|
drive: '{"text":"Fichier Drive $drive.file_name ajouté dans $drive.file_path"}',
|
||||||
|
contacts: '{"text":"Contact $contact.name ($contact.email) mis à jour"}',
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user