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:
R3D347HR4Y 2026-06-07 15:51:47 +02:00
parent 6ec95262af
commit 20552a34ff
21 changed files with 1385 additions and 181 deletions

View File

@ -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)
}

View File

@ -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,25 +52,94 @@ 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">
<div className="grid gap-2 sm:grid-cols-2"> Tester avec un événement exemple
<div> {domains.length > 0 ? (
<Label className="text-[10px]">De</Label> <span className="ml-1 font-normal text-muted-foreground">
<Input className="h-8 text-xs" value={message.from} onChange={(e) => setMessage({ ...message, from: e.target.value })} /> ({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>
<Label className="text-[10px]">De</Label>
<Input className="h-8 text-xs" value={message.from} onChange={(e) => setMessage({ ...message, from: e.target.value })} />
</div>
<div>
<Label className="text-[10px]">Sujet</Label>
<Input className="h-8 text-xs" value={message.subject} onChange={(e) => setMessage({ ...message, subject: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[10px]">Corps</Label>
<textarea
className="mt-1 min-h-16 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
value={message.body_text}
onChange={(e) => setMessage({ ...message, body_text: e.target.value })}
/>
</div>
</div> </div>
<div> ) : null}
<Label className="text-[10px]">Sujet</Label>
<Input className="h-8 text-xs" value={message.subject} onChange={(e) => setMessage({ ...message, subject: e.target.value })} /> {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> </div>
</div> ) : null}
<div>
<Label className="text-[10px]">Corps</Label> {domains.includes('contacts') ? (
<textarea <div className="space-y-2 rounded-md border border-border/50 p-2">
className="mt-1 min-h-16 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs" <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Contacts</p>
value={message.body_text} <div className="grid gap-2 sm:grid-cols-2">
onChange={(e) => setMessage({ ...message, body_text: e.target.value })} <div>
/> <Label className="text-[10px]">Nom</Label>
</div> <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&apos;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

View File

@ -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>
) )
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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&apos;é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>

View File

@ -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 className="flex shrink-0 gap-1">
<Button type="button" variant="ghost" size="icon" onClick={() => openEdit(hook)}>
<Pencil className="size-4" />
</Button>
<Button type="button" variant="ghost" size="icon" onClick={() => deleteWebhook.mutate(hook.id)}>
<Trash2 className="size-4" />
</Button>
</div> </div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => deleteWebhook.mutate(hook.id)}
>
<Trash2 className="size-4" />
</Button>
</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>
) )
} }

View File

@ -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,

View File

@ -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(

View File

@ -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">
<Label className="text-xs font-medium">Déclencheurs (OU entre groupes, ET dans un groupe)</Label> <div>
<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) => (
<SelectItem key={t} value={t} className="text-xs"> <SelectGroup key={domain}>
{TRIGGER_LABELS[t]} <SelectLabel className="text-[10px]">{AUTOMATION_DOMAIN_LABELS[domain]}</SelectLabel>
</SelectItem> {DOMAIN_TRIGGER_TYPES[domain].map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{TRIGGER_LABELS[t]}
</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"

View File

@ -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">

View File

@ -120,16 +120,35 @@ export function useMailWebhooks() {
}) })
} }
export type MailWebhookPayload = {
name: string
url: string
method?: string
body_template?: string
event_types?: string[]
mail_scope?: ApiWebhook['mail_scope']
drive_scope?: ApiWebhook['drive_scope']
contacts_scope?: ApiWebhook['contacts_scope']
}
export function useCreateMailWebhook() { export function useCreateMailWebhook() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: { mutationFn: (payload: MailWebhookPayload) =>
name: string apiClient.post<{ id: string }>('/mail/webhooks', payload),
url: string onSuccess: () => {
method?: string queryClient.invalidateQueries({ queryKey: ['mail-webhooks'] })
body_template?: string },
}) => apiClient.post<{ id: string }>('/mail/webhooks', payload), })
}
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'] })
}, },

View File

@ -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 {

View File

@ -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'
} }

View File

@ -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 = {

View 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
}

View File

@ -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',

View File

@ -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

View File

@ -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 []
} }

View 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(),
}
}

View File

@ -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"}',
}