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'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Play } from 'lucide-react'
|
||||
import { useSimulateMailRule } from '@/lib/api/hooks/use-mail-automation-queries'
|
||||
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 {
|
||||
state: RuleEditorState
|
||||
@ -17,8 +24,15 @@ interface RuleSimulatorPanelProps {
|
||||
export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
||||
const simulate = useSimulateMailRule()
|
||||
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 domains = useMemo(
|
||||
() => inferDomainsFromTriggers(state.workflow.triggers),
|
||||
[state.workflow.triggers]
|
||||
)
|
||||
|
||||
async function runSimulation() {
|
||||
const payload = workflowToApiPayload(state)
|
||||
const res = await simulate.mutateAsync({
|
||||
@ -38,25 +52,94 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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 })} />
|
||||
<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>
|
||||
<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>
|
||||
<Label className="text-[10px]">Sujet</Label>
|
||||
<Input className="h-8 text-xs" value={message.subject} onChange={(e) => setMessage({ ...message, subject: e.target.value })} />
|
||||
) : 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>
|
||||
</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>
|
||||
) : 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}>
|
||||
<Play className="mr-1 size-3.5" />
|
||||
Simuler
|
||||
|
||||
@ -23,6 +23,7 @@ import { Plus, Variable } from 'lucide-react'
|
||||
import { workflowNodeTypes } from './workflow-nodes'
|
||||
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
|
||||
import { WorkflowNodeInspector } from './workflow-node-inspector'
|
||||
import { AutomationDomainProvider } from './automation-domain-context'
|
||||
import {
|
||||
PALETTE_NODE_TYPES,
|
||||
NODE_TYPE_LABELS,
|
||||
@ -166,6 +167,7 @@ function RuleWorkflowEditorInner({
|
||||
}
|
||||
|
||||
return (
|
||||
<AutomationDomainProvider triggers={state.workflow.triggers}>
|
||||
<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">
|
||||
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
|
||||
@ -265,11 +267,13 @@ function RuleWorkflowEditorInner({
|
||||
<WorkflowNodeInspector
|
||||
node={selectedNode}
|
||||
allRules={allRules}
|
||||
triggers={state.workflow.triggers}
|
||||
onUpdate={updateNodeData}
|
||||
onDelete={deleteNode}
|
||||
/>
|
||||
</aside>
|
||||
</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 { toast } from "sonner"
|
||||
import {
|
||||
WEBHOOK_TEMPLATE_VARIABLE_GROUPS,
|
||||
webhookVariableGroupsForDomains,
|
||||
type WebhookTemplateVariable,
|
||||
} from "@/lib/mail-automation/webhook-template-variables"
|
||||
import {
|
||||
AUTOMATION_DOMAIN_LABELS,
|
||||
type AutomationDomain,
|
||||
} from "@/lib/mail-automation/domains"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DOMAIN_TABS: Array<AutomationDomain | "all"> = ["all", "mail", "drive", "contacts"]
|
||||
|
||||
function VariableChip({
|
||||
variable,
|
||||
copied,
|
||||
@ -43,6 +49,12 @@ function VariableChip({
|
||||
|
||||
export function WebhookTemplateVariablesPanel() {
|
||||
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) => {
|
||||
try {
|
||||
@ -59,17 +71,33 @@ export function WebhookTemplateVariablesPanel() {
|
||||
|
||||
return (
|
||||
<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-xs text-muted-foreground">
|
||||
Insérez ces variables dans votre JSON{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">body_template</code>.
|
||||
Cliquez sur une puce pour la copier dans le presse-papiers.
|
||||
Variables selon le type d'événement (mail, Drive, contacts). Les variables communes
|
||||
($event.*, $date) fonctionnent partout.
|
||||
</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 className="space-y-4">
|
||||
{WEBHOOK_TEMPLATE_VARIABLE_GROUPS.map((group) => (
|
||||
{groups.map((group) => (
|
||||
<section key={group.id} className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium">{group.label}</h3>
|
||||
|
||||
@ -1,62 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Pencil, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
useMailWebhooks,
|
||||
useCreateMailWebhook,
|
||||
useUpdateMailWebhook,
|
||||
useDeleteMailWebhook,
|
||||
} from "@/lib/api/hooks/use-mail-automation-queries"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { WebhookTemplateVariablesPanel } from "@/components/gmail/settings/automation/webhook-template-variables-panel"
|
||||
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() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: webhooks = [], isFetching, isError, refetch, isPending } = useMailWebhooks()
|
||||
const createWebhook = useCreateMailWebhook()
|
||||
const updateWebhook = useUpdateMailWebhook()
|
||||
const deleteWebhook = useDeleteMailWebhook()
|
||||
const [name, setName] = useState("")
|
||||
const [url, setUrl] = useState("")
|
||||
const [template, setTemplate] = useState(
|
||||
'{"text":"Nouveau mail de $sender.name : $subject"}'
|
||||
)
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [presetDomain, setPresetDomain] = useState<AutomationDomain>("mail")
|
||||
const [form, setForm] = useState<WebhookFormState>(() => createDefaultWebhookForm("mail"))
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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}>
|
||||
<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 ? (
|
||||
<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">
|
||||
{webhooks.map((hook) => (
|
||||
<li
|
||||
key={hook.id}
|
||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
||||
>
|
||||
<li key={hook.id} className="flex items-start justify-between gap-2 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{hook.name}</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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteWebhook.mutate(hook.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,17 +5,24 @@ import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import { NODE_TYPE_LABELS } from '@/lib/mail-automation/node-definitions'
|
||||
import {
|
||||
ACTION_TYPES,
|
||||
CONDITION_FIELDS,
|
||||
NODE_TYPE_LABELS,
|
||||
} from '@/lib/mail-automation/node-definitions'
|
||||
actionTypesForDomains,
|
||||
AUTOMATION_DOMAIN_LABELS,
|
||||
conditionFieldsForDomains,
|
||||
inferDomainsFromTriggers,
|
||||
primaryDomainFromTriggers,
|
||||
} from '@/lib/mail-automation/domains'
|
||||
import { useAutomationDomains } from './automation-domain-context'
|
||||
import { isBooleanConditionField, isLabelConditionField } from '@/lib/mail-automation/condition-helpers'
|
||||
import {
|
||||
actionValueSuggestionKind,
|
||||
conditionValueSuggestionKind,
|
||||
@ -44,6 +51,7 @@ interface WorkflowNodeInspectorProps {
|
||||
allRules: ApiRule[]
|
||||
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
triggers?: import('@/lib/mail-automation/types').TriggerOrGroup
|
||||
}
|
||||
|
||||
export function WorkflowNodeInspector({
|
||||
@ -51,7 +59,16 @@ export function WorkflowNodeInspector({
|
||||
allRules,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
triggers,
|
||||
}: 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') {
|
||||
return (
|
||||
<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">
|
||||
{type === 'condition' || type === 'label_check' ? (
|
||||
<ConditionEditor
|
||||
fields={conditionFields}
|
||||
data={
|
||||
type === 'label_check'
|
||||
? {
|
||||
@ -101,13 +119,18 @@ export function WorkflowNodeInspector({
|
||||
/>
|
||||
) : null}
|
||||
{type === 'switch' ? (
|
||||
<SwitchEditor data={data as unknown as SwitchNodeData} onChange={patch} />
|
||||
<SwitchEditor fields={conditionFields} data={data as unknown as SwitchNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{type === 'llm_check' ? (
|
||||
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{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}
|
||||
{type === 'set_var' ? (
|
||||
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
|
||||
@ -126,9 +149,11 @@ export function WorkflowNodeInspector({
|
||||
}
|
||||
|
||||
function ConditionEditor({
|
||||
fields,
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
||||
data: ConditionNodeData
|
||||
onChange: (p: Partial<ConditionNodeData>) => void
|
||||
}) {
|
||||
@ -140,30 +165,30 @@ function ConditionEditor({
|
||||
onChange({
|
||||
field,
|
||||
operator: defaultOperatorForField(field),
|
||||
value: field === 'has_attachment' ? 'true' : '',
|
||||
value: isBooleanConditionField(field) ? 'true' : '',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldSelect
|
||||
<DomainGroupedFieldSelect
|
||||
label="Champ"
|
||||
value={data.field}
|
||||
options={CONDITION_FIELDS}
|
||||
fields={fields}
|
||||
onChange={(v) => onFieldChange(v as ConditionField)}
|
||||
/>
|
||||
<FieldSelect
|
||||
label={data.field === 'label' ? 'Mode' : 'Opérateur'}
|
||||
label={isLabelConditionField(data.field) ? 'Mode' : 'Opérateur'}
|
||||
value={data.operator}
|
||||
options={operatorOptions}
|
||||
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
|
||||
/>
|
||||
<div>
|
||||
<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>
|
||||
<div className="mt-1">
|
||||
{data.field === 'has_attachment' ? (
|
||||
{isBooleanConditionField(data.field) ? (
|
||||
<FieldSelect
|
||||
label=""
|
||||
value={data.value || 'true'}
|
||||
@ -188,9 +213,11 @@ function ConditionEditor({
|
||||
}
|
||||
|
||||
function SwitchEditor({
|
||||
fields,
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
fields: { value: ConditionField; label: string; domain: import('@/lib/mail-automation/domains').AutomationDomain }[]
|
||||
data: SwitchNodeData
|
||||
onChange: (p: Partial<SwitchNodeData>) => void
|
||||
}) {
|
||||
@ -199,7 +226,12 @@ function SwitchEditor({
|
||||
|
||||
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">
|
||||
<Label className="text-xs">Cas de sortie</Label>
|
||||
{cases.map((c, i) => (
|
||||
@ -271,17 +303,23 @@ function LLMEditor({
|
||||
}
|
||||
|
||||
function ActionsEditor({
|
||||
actionTypes,
|
||||
primaryDomain,
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
actionTypes: ReturnType<typeof actionTypesForDomains>
|
||||
primaryDomain: import('@/lib/mail-automation/domains').AutomationDomain
|
||||
data: ActionsNodeData
|
||||
onChange: (p: Partial<ActionsNodeData>) => void
|
||||
}) {
|
||||
const actions = data.actions ?? []
|
||||
const grouped = ['universal', 'mail', 'drive', 'contacts'] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{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)
|
||||
return (
|
||||
<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>
|
||||
<SelectContent>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<SelectItem key={a.value} value={a.value} className="text-xs">{a.label}</SelectItem>
|
||||
))}
|
||||
{grouped.map((group) => {
|
||||
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>
|
||||
</Select>
|
||||
{meta?.needsValue ? (
|
||||
@ -315,7 +366,13 @@ function ActionsEditor({
|
||||
</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" />
|
||||
Ajouter une action
|
||||
</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({
|
||||
label,
|
||||
value,
|
||||
|
||||
@ -5,11 +5,10 @@ import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
NODE_COLORS,
|
||||
CONDITION_FIELDS,
|
||||
CONDITION_OPERATORS,
|
||||
LABEL_CONDITION_OPERATORS,
|
||||
ACTION_TYPES,
|
||||
} from '@/lib/mail-automation/node-definitions'
|
||||
import { actionTypeLabel, conditionFieldLabel } from '@/lib/mail-automation/domains'
|
||||
import { formatConditionSummary } from '@/lib/mail-automation/condition-helpers'
|
||||
import type {
|
||||
ActionsNodeData,
|
||||
@ -23,7 +22,7 @@ import type {
|
||||
} from '@/lib/mail-automation/types'
|
||||
|
||||
function fieldLabel(field: string) {
|
||||
return CONDITION_FIELDS.find((f) => f.value === field)?.label ?? field
|
||||
return conditionFieldLabel(field)
|
||||
}
|
||||
|
||||
function opLabel(op: string) {
|
||||
@ -33,7 +32,7 @@ function opLabel(op: string) {
|
||||
}
|
||||
|
||||
function actionLabel(type: string) {
|
||||
return ACTION_TYPES.find((a) => a.value === type)?.label ?? type
|
||||
return actionTypeLabel(type)
|
||||
}
|
||||
|
||||
function nodeShell(
|
||||
|
||||
@ -5,7 +5,9 @@ import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
@ -16,10 +18,23 @@ import type {
|
||||
TriggerOrGroup,
|
||||
TriggerType,
|
||||
} 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'
|
||||
|
||||
const TRIGGER_TYPES: TriggerType[] = ['message_received', 'label_added', 'label_removed']
|
||||
const ALL_DOMAINS: AutomationDomain[] = ['mail', 'drive', 'contacts']
|
||||
|
||||
interface WorkflowTriggersPanelProps {
|
||||
triggers: TriggerOrGroup
|
||||
@ -37,6 +52,9 @@ export function WorkflowTriggersPanel({
|
||||
? triggers.groups
|
||||
: [{ operator: 'and' as const, items: [{ type: 'message_received' as const }] }]
|
||||
|
||||
const activeDomains = inferDomainsFromTriggers(triggers)
|
||||
const multiDomain = activeDomains.length > 1
|
||||
|
||||
function updateGroups(next: TriggerAndGroup[]) {
|
||||
onChange({
|
||||
operator: 'or',
|
||||
@ -53,7 +71,7 @@ export function WorkflowTriggersPanel({
|
||||
function addOrGroup() {
|
||||
onChange({
|
||||
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 (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
|
||||
<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}>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Groupe OU
|
||||
@ -130,7 +156,7 @@ export function WorkflowTriggersPanel({
|
||||
onClick={() =>
|
||||
updateGroup(gi, {
|
||||
...group,
|
||||
items: [...group.items, { type: 'message_received' }],
|
||||
items: [...group.items, defaultTriggerForDomain(activeDomains[0] ?? 'mail')],
|
||||
})
|
||||
}
|
||||
>
|
||||
@ -154,50 +180,82 @@ function TriggerRow({
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
function onTypeChange(type: TriggerType) {
|
||||
const domain = triggerDomain(type)
|
||||
const base = defaultTriggerForDomain(domain)
|
||||
onChange({ ...base, type })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Type</Label>
|
||||
<Select
|
||||
value={item.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange({ ...item, type: v as TriggerType })}
|
||||
>
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Événement</Label>
|
||||
<Select value={item.type} disabled={disabled} onValueChange={(v) => onTypeChange(v as TriggerType)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs">
|
||||
{TRIGGER_LABELS[t]}
|
||||
</SelectItem>
|
||||
{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">
|
||||
{TRIGGER_LABELS[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{item.type === 'message_received' ? (
|
||||
{triggerUsesMailFolder(item.type) ? (
|
||||
<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
|
||||
kind="folder_id"
|
||||
kind={triggerValueSuggestionKind('folder_id')}
|
||||
placeholder="Choisir un dossier…"
|
||||
value={item.folder_id ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange({ ...item, folder_id: value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
{triggerUsesMailLabel(item.type) ? (
|
||||
<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
|
||||
kind="label"
|
||||
kind={triggerValueSuggestionKind('label')}
|
||||
placeholder="Nom libellé"
|
||||
value={item.label ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange({ ...item, label: value || undefined })}
|
||||
/>
|
||||
</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@ -13,7 +13,7 @@ export function AutomationSettingsSection() {
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
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">
|
||||
<TabsList className="flex h-auto flex-wrap">
|
||||
|
||||
@ -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() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
name: string
|
||||
url: string
|
||||
method?: string
|
||||
body_template?: string
|
||||
}) => apiClient.post<{ id: string }>('/mail/webhooks', payload),
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-webhooks'] })
|
||||
},
|
||||
|
||||
@ -189,6 +189,11 @@ export interface ApiRule {
|
||||
account_id?: string
|
||||
}
|
||||
|
||||
export interface ApiWebhookContactsScope {
|
||||
all_books: boolean
|
||||
book_ids: string[]
|
||||
}
|
||||
|
||||
export interface ApiWebhook {
|
||||
id: string
|
||||
name: string
|
||||
@ -198,6 +203,10 @@ export interface ApiWebhook {
|
||||
body_template?: string
|
||||
max_retries?: number
|
||||
is_active?: boolean
|
||||
event_types?: string[]
|
||||
mail_scope?: ApiTokenMailScope
|
||||
drive_scope?: ApiTokenDriveScope
|
||||
contacts_scope?: ApiWebhookContactsScope
|
||||
}
|
||||
|
||||
export interface ApiTokenPermissionGrant {
|
||||
|
||||
@ -20,28 +20,49 @@ export const ATTACHMENT_CONDITION_OPERATORS: { value: ConditionOperator; label:
|
||||
]
|
||||
|
||||
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 {
|
||||
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[] {
|
||||
if (field === 'label') return ['has', 'not_has']
|
||||
if (field === 'has_attachment') return ['equals']
|
||||
if (isLabelConditionField(field)) return ['has', 'not_has']
|
||||
if (isBooleanConditionField(field)) return ['equals']
|
||||
if (isNumericConditionField(field)) return ['equals', 'contains']
|
||||
return STRING_CONDITION_OPERATORS.map((o) => o.value)
|
||||
}
|
||||
|
||||
export function operatorOptionsForConditionField(field: ConditionField) {
|
||||
if (field === 'label') return LABEL_CONDITION_OPERATORS
|
||||
if (field === 'has_attachment') return ATTACHMENT_CONDITION_OPERATORS
|
||||
if (isLabelConditionField(field)) return LABEL_CONDITION_OPERATORS
|
||||
if (isBooleanConditionField(field) || isNumericConditionField(field)) return ATTACHMENT_CONDITION_OPERATORS
|
||||
return STRING_CONDITION_OPERATORS
|
||||
}
|
||||
|
||||
export function defaultOperatorForField(field: ConditionField): ConditionOperator {
|
||||
if (field === 'label') return 'has'
|
||||
if (field === 'has_attachment') return 'equals'
|
||||
if (isLabelConditionField(field)) return 'has'
|
||||
if (isBooleanConditionField(field) || isNumericConditionField(field)) return 'equals'
|
||||
return 'contains'
|
||||
}
|
||||
|
||||
@ -50,28 +71,41 @@ export function formatConditionSummary(data: {
|
||||
operator: ConditionOperator
|
||||
value: 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é'
|
||||
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 || '…'} »`
|
||||
}
|
||||
|
||||
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(
|
||||
field: ConditionField,
|
||||
operator: ConditionOperator
|
||||
_operator: ConditionOperator
|
||||
): AutomationSuggestionKind {
|
||||
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'
|
||||
}
|
||||
|
||||
export function switchFieldSuggestionKind(field: string): AutomationSuggestionKind {
|
||||
if (field === 'label') return 'label'
|
||||
if (field === 'from' || field === 'to') return 'email'
|
||||
return 'none'
|
||||
return conditionValueSuggestionKind(field as ConditionField, 'contains')
|
||||
}
|
||||
|
||||
export function actionValueSuggestionKind(type: ActionType): AutomationSuggestionKind {
|
||||
@ -83,7 +117,14 @@ export function actionValueSuggestionKind(type: ActionType): AutomationSuggestio
|
||||
return 'folder'
|
||||
case 'forward':
|
||||
case 'send_mail':
|
||||
case 'drive_share':
|
||||
return 'email'
|
||||
case 'drive_move':
|
||||
case 'drive_copy':
|
||||
return 'drive_path'
|
||||
case 'contact_add_label':
|
||||
case 'contact_remove_label':
|
||||
return 'contact_label'
|
||||
case 'webhook':
|
||||
return 'webhook'
|
||||
default:
|
||||
@ -92,10 +133,11 @@ export function actionValueSuggestionKind(type: ActionType): AutomationSuggestio
|
||||
}
|
||||
|
||||
export function triggerValueSuggestionKind(
|
||||
type: 'message_received' | 'label_added' | 'label_removed',
|
||||
field: 'folder_id' | 'label'
|
||||
field: 'folder_id' | 'label' | 'folder_path' | 'contact_label'
|
||||
): AutomationSuggestionKind {
|
||||
if (field === 'label') return 'label'
|
||||
if (field === 'folder_id') return 'folder_id'
|
||||
if (field === 'folder_path') return 'drive_path'
|
||||
if (field === 'contact_label') return 'contact_label'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
import {
|
||||
defaultActionForDomain,
|
||||
defaultConditionFieldForDomain,
|
||||
defaultTriggerForDomain,
|
||||
} from './domains'
|
||||
import type { AutomationDomain } from './domains'
|
||||
import type {
|
||||
ActionItem,
|
||||
AutomationWorkflow,
|
||||
@ -13,11 +19,16 @@ export function nextNodeId(prefix: string) {
|
||||
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 condId = nextNodeId('cond')
|
||||
const actionsId = nextNodeId('actions')
|
||||
const endId = nextNodeId('end')
|
||||
const conditionField = defaultConditionFieldForDomain(domain)
|
||||
const defaultAction = defaultActionForDomain(domain)
|
||||
|
||||
const nodes: WorkflowNodeDef[] = [
|
||||
{ id: startId, type: 'start', position: { x: 80, y: 200 }, data: {} },
|
||||
@ -25,13 +36,13 @@ export function createDefaultWorkflow(kind: 'rule' | 'function' = 'rule'): Autom
|
||||
id: condId,
|
||||
type: 'condition',
|
||||
position: { x: 280, y: 180 },
|
||||
data: { field: 'subject', operator: 'contains', value: '' },
|
||||
data: { field: conditionField, operator: 'contains', value: '' },
|
||||
},
|
||||
{
|
||||
id: actionsId,
|
||||
type: 'actions',
|
||||
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: {} },
|
||||
]
|
||||
@ -51,7 +62,7 @@ export function createDefaultWorkflow(kind: 'rule' | 'function' = 'rule'): Autom
|
||||
groups: [
|
||||
{
|
||||
operator: 'and',
|
||||
items: [{ type: 'message_received' }],
|
||||
items: [defaultTriggerForDomain(domain)],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -73,8 +84,24 @@ export function createDefaultRuleEditorState(
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyAction(): ActionItem {
|
||||
return { type: 'label', value: '' }
|
||||
export function createEmptyAction(domain: AutomationDomain = 'mail'): ActionItem {
|
||||
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 = {
|
||||
|
||||
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 {
|
||||
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 {
|
||||
LABEL_CONDITION_OPERATORS,
|
||||
STRING_CONDITION_OPERATORS,
|
||||
@ -6,12 +17,7 @@ import {
|
||||
} from './condition-helpers'
|
||||
|
||||
export { LABEL_CONDITION_OPERATORS, STRING_CONDITION_OPERATORS, operatorOptionsForConditionField }
|
||||
|
||||
export const TRIGGER_LABELS: Record<string, string> = {
|
||||
message_received: 'Mail reçu',
|
||||
label_added: 'Libellé ajouté',
|
||||
label_removed: 'Libellé retiré',
|
||||
}
|
||||
export { TRIGGER_LABELS, CONDITION_FIELD_LABELS, ACTION_TYPE_META }
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<WorkflowNodeType, string> = {
|
||||
start: 'Début',
|
||||
@ -28,7 +34,7 @@ export const NODE_TYPE_LABELS: Record<WorkflowNodeType, string> = {
|
||||
|
||||
export const NODE_TYPE_DESCRIPTIONS: Record<WorkflowNodeType, string> = {
|
||||
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)',
|
||||
switch: 'Branchement multi-cas',
|
||||
llm_check: 'Décision via prompt LLM',
|
||||
@ -39,38 +45,36 @@ export const NODE_TYPE_DESCRIPTIONS: Record<WorkflowNodeType, string> = {
|
||||
end: 'Termine le flux',
|
||||
}
|
||||
|
||||
export const CONDITION_FIELDS: { value: ConditionField; label: string }[] = [
|
||||
{ value: 'from', label: 'Expéditeur' },
|
||||
{ value: 'to', label: 'Destinataire' },
|
||||
{ value: 'subject', label: 'Sujet' },
|
||||
{ value: 'body', label: 'Corps' },
|
||||
{ value: 'has_attachment', label: 'Pièce jointe' },
|
||||
{ value: 'label', label: 'Libellé du message' },
|
||||
]
|
||||
/** @deprecated Préférer conditionFieldsForDomains(activeDomains) */
|
||||
export const CONDITION_FIELDS: { value: ConditionField; label: string }[] = conditionFieldsForDomains([
|
||||
'mail',
|
||||
'drive',
|
||||
'contacts',
|
||||
]).map(({ value, label }) => ({ value, label }))
|
||||
|
||||
export const CONDITION_OPERATORS = STRING_CONDITION_OPERATORS
|
||||
|
||||
/** @deprecated Préférer actionTypesForDomains(activeDomains) */
|
||||
export const ACTION_TYPES: {
|
||||
value: ActionType
|
||||
label: string
|
||||
needsValue: boolean
|
||||
placeholder?: string
|
||||
}[] = [
|
||||
{ value: 'label', label: 'Ajouter libellé', needsValue: true, placeholder: 'Nom du libellé' },
|
||||
{ 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 },
|
||||
{ value: 'delete', label: 'Supprimer', needsValue: false },
|
||||
{ value: 'mark_read', label: 'Marquer lu', needsValue: false },
|
||||
{ value: 'mark_important', label: 'Marquer important', needsValue: false },
|
||||
{ value: 'mark_spam', label: 'Marquer spam', needsValue: false },
|
||||
{ value: 'star', label: 'Suivi / étoile', needsValue: false },
|
||||
{ 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' },
|
||||
{ value: 'send_mail', label: 'Envoyer un mail', needsValue: true, placeholder: 'dest@example.com' },
|
||||
{ value: 'forward', label: 'Transférer', needsValue: true, placeholder: 'dest@example.com' },
|
||||
]
|
||||
}[] = actionTypesForDomains(['mail', 'drive', 'contacts']).map(
|
||||
({ value, label, needsValue, placeholder }) => ({ value, label, needsValue, placeholder })
|
||||
)
|
||||
|
||||
export function conditionFieldsForWorkflow(triggers: TriggerOrGroup) {
|
||||
return conditionFieldsForDomains(inferDomainsFromTriggers(triggers))
|
||||
}
|
||||
|
||||
export function actionTypesForWorkflow(triggers: TriggerOrGroup) {
|
||||
return actionTypesForDomains(inferDomainsFromTriggers(triggers))
|
||||
}
|
||||
|
||||
export function primaryDomainForWorkflow(triggers: TriggerOrGroup): AutomationDomain {
|
||||
return primaryDomainFromTriggers(triggers)
|
||||
}
|
||||
|
||||
export const PALETTE_NODE_TYPES: WorkflowNodeType[] = [
|
||||
'condition',
|
||||
|
||||
@ -2,13 +2,28 @@
|
||||
|
||||
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 {
|
||||
type: TriggerType
|
||||
folder_id?: string
|
||||
label?: 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 {
|
||||
@ -46,6 +61,16 @@ export type ConditionField =
|
||||
| 'body'
|
||||
| 'has_attachment'
|
||||
| '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 =
|
||||
| 'contains'
|
||||
@ -101,6 +126,14 @@ export type ActionType =
|
||||
| 'reply'
|
||||
| 'send_mail'
|
||||
| 'forward'
|
||||
| 'drive_move'
|
||||
| 'drive_rename'
|
||||
| 'drive_delete'
|
||||
| 'drive_share'
|
||||
| 'drive_copy'
|
||||
| 'contact_add_label'
|
||||
| 'contact_remove_label'
|
||||
| 'contact_delete'
|
||||
|
||||
export interface ActionItem {
|
||||
type: ActionType
|
||||
@ -162,6 +195,22 @@ export interface RuleSimulationMessage {
|
||||
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 {
|
||||
node_id: string
|
||||
node_type: string
|
||||
|
||||
@ -108,6 +108,22 @@ export function useAutomationSuggestions() {
|
||||
return emailSuggestions
|
||||
case 'webhook':
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
import type { AutomationDomain } from './domains'
|
||||
|
||||
export type WebhookTemplateVariableGroup = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
domain: AutomationDomain | 'universal'
|
||||
variables: WebhookTemplateVariable[]
|
||||
}
|
||||
|
||||
/** Variables interpolées côté backend (`internal/mail/webhooks/executor.go`). */
|
||||
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",
|
||||
label: "Expéditeur",
|
||||
description: "Identité de l'expéditeur du message déclencheur.",
|
||||
domain: "mail",
|
||||
variables: [
|
||||
{
|
||||
token: "$sender.name",
|
||||
@ -37,6 +67,7 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
||||
id: "message",
|
||||
label: "Message",
|
||||
description: "Métadonnées principales du mail.",
|
||||
domain: "mail",
|
||||
variables: [
|
||||
{
|
||||
token: "$subject",
|
||||
@ -50,18 +81,13 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
||||
description: "Identifiant interne Ultimail du message.",
|
||||
example: "msg-123",
|
||||
},
|
||||
{
|
||||
token: "$date",
|
||||
label: "Date",
|
||||
description: "Date de réception au format ISO 8601.",
|
||||
example: "2026-05-22T10:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "body",
|
||||
label: "Contenu",
|
||||
description: "Corps du message, en texte ou HTML.",
|
||||
domain: "mail",
|
||||
variables: [
|
||||
{
|
||||
token: "$body.textContent",
|
||||
@ -81,6 +107,7 @@ export const WEBHOOK_TEMPLATE_VARIABLE_GROUPS: WebhookTemplateVariableGroup[] =
|
||||
id: "recipients",
|
||||
label: "Destinataires",
|
||||
description: "Adresses en copie directe (To).",
|
||||
domain: "mail",
|
||||
variables: [
|
||||
{
|
||||
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(
|
||||
(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