@fayz-ai/plugin-automations 0.12.6 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/flows.d.ts +66 -0
- package/dist/data/flows.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1554 -22
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/views/FlowsPage.d.ts +3 -0
- package/dist/views/FlowsPage.d.ts.map +1 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import * as React4 from 'react';
|
|
2
|
-
import React4__default from 'react';
|
|
3
|
-
import { getSupabaseClientOptional, startEventLog, getMessageDefaults, normalizeMessageActions, usePluginRuntimeOptional, resolveMessages,
|
|
4
|
-
import { resolveFayzAgentConnection, registerRailPage, getFayzSupabaseClientOptional, useConnectors, useAgentSurface,
|
|
2
|
+
import React4__default, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
3
|
+
import { getSupabaseClientOptional, startEventLog, getMessageDefaults, normalizeMessageActions, useTranslation, errorMessage, usePluginRuntimeOptional, resolveMessages, resolveMessage, renderMessageTemplate, loadEventRuns, getActiveTenantId } from '@fayz-ai/core';
|
|
4
|
+
import { resolveFayzAgentConnection, registerRailPage, getFayzSupabaseClientOptional, PageActions, useConnectors, useAgentSurface, askAI, SettingsTabs, adminNavigateTo, RightRailPage, AskAIButton, useOrganizationStore, connectorRoute, ConnectorMark, ConnectorDeliveries } from '@fayz-ai/admin';
|
|
5
5
|
import * as LucideIcons from 'lucide-react';
|
|
6
|
-
import { WandSparkles, Zap, Receipt, Megaphone, MessageSquare, Smartphone, Mail,
|
|
7
|
-
import {
|
|
6
|
+
import { Loader2, Plus, ChevronRight, WandSparkles, Zap, Receipt, Megaphone, MessageSquare, Smartphone, Mail, RotateCcw, Check, Clock, Monitor, ListChecks, Trash2, RefreshCw, History, Send, AlertTriangle, CircleCheck, CircleSlash } from 'lucide-react';
|
|
7
|
+
import { toast, PageHeaderActions, Card, Switch, DataTable, PageTabs, WhatsAppIcon, SubpageHeader, Button, Badge, Hint, HeaderTabs, PhoneField } from '@fayz-ai/ui';
|
|
8
8
|
import { createFayzAutomationsClient, FayzAutomationsError } from '@fayz-ai/sdk/automations';
|
|
9
9
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
10
10
|
|
|
@@ -591,7 +591,7 @@ var SAMPLE = {
|
|
|
591
591
|
appointment_date: "12/08/2026",
|
|
592
592
|
appointment_time: "14:30"
|
|
593
593
|
};
|
|
594
|
-
function AutomationDetailPage({ eventKey, channel, store, backTo = "/automations", embedded, onDone }) {
|
|
594
|
+
function AutomationDetailPage({ eventKey, channel, store: store2, backTo = "/automations", embedded, onDone }) {
|
|
595
595
|
const tenantId = useOrganizationStoreLike();
|
|
596
596
|
const runtime = usePluginRuntimeOptional();
|
|
597
597
|
const fayzConnection = React4__default.useMemo(() => resolveFayzAgentConnection(), []);
|
|
@@ -652,14 +652,14 @@ function AutomationDetailPage({ eventKey, channel, store, backTo = "/automations
|
|
|
652
652
|
}
|
|
653
653
|
let overrides = [];
|
|
654
654
|
try {
|
|
655
|
-
overrides = await
|
|
655
|
+
overrides = await store2.list(tenantId);
|
|
656
656
|
} catch (err) {
|
|
657
657
|
console.warn("[automations] could not load overrides; showing defaults", err);
|
|
658
658
|
}
|
|
659
659
|
const found = resolveMessage(decodeURIComponent(eventKey), channel, overrides, runtime?.events ?? []);
|
|
660
660
|
setMessage(found);
|
|
661
661
|
if (found) setDraft({ subject: found.subject, body: found.body });
|
|
662
|
-
}, [eventKey, channel,
|
|
662
|
+
}, [eventKey, channel, store2, tenantId, runtime]);
|
|
663
663
|
React4__default.useEffect(() => {
|
|
664
664
|
void reload();
|
|
665
665
|
}, [reload]);
|
|
@@ -703,7 +703,7 @@ function AutomationDetailPage({ eventKey, channel, store, backTo = "/automations
|
|
|
703
703
|
const patch = async (input) => {
|
|
704
704
|
setSaving(true);
|
|
705
705
|
try {
|
|
706
|
-
await
|
|
706
|
+
await store2.upsert(input, tenantId);
|
|
707
707
|
await reload();
|
|
708
708
|
} catch (err) {
|
|
709
709
|
console.warn("[automations] save failed", err);
|
|
@@ -723,7 +723,7 @@ function AutomationDetailPage({ eventKey, channel, store, backTo = "/automations
|
|
|
723
723
|
setDraft({ subject: fallback?.subject ?? "", body: fallback?.body ?? "" });
|
|
724
724
|
setSaving(true);
|
|
725
725
|
try {
|
|
726
|
-
await
|
|
726
|
+
await store2.reset(event.name, message.channel, tenantId);
|
|
727
727
|
await reload();
|
|
728
728
|
toast.success("Texto restaurado");
|
|
729
729
|
} catch (err) {
|
|
@@ -1207,7 +1207,7 @@ function readSelection(basePath) {
|
|
|
1207
1207
|
if (!eventKey || !channel) return null;
|
|
1208
1208
|
return { eventKey, channel };
|
|
1209
1209
|
}
|
|
1210
|
-
function AutomationsList({ store, variant = "page", basePath = "/automations" }) {
|
|
1210
|
+
function AutomationsList({ store: store2, variant = "page", basePath = "/automations" }) {
|
|
1211
1211
|
const tenantId = useOrganizationStoreLike();
|
|
1212
1212
|
const runtime = usePluginRuntimeOptional();
|
|
1213
1213
|
const [messages, setMessages] = React4__default.useState(null);
|
|
@@ -1225,17 +1225,17 @@ function AutomationsList({ store, variant = "page", basePath = "/automations" })
|
|
|
1225
1225
|
const reload = React4__default.useCallback(async () => {
|
|
1226
1226
|
let overrides = [];
|
|
1227
1227
|
try {
|
|
1228
|
-
overrides = await
|
|
1228
|
+
overrides = await store2.list(tenantId);
|
|
1229
1229
|
} catch (err) {
|
|
1230
1230
|
console.warn("[automations] could not load template overrides; showing defaults", err);
|
|
1231
1231
|
}
|
|
1232
1232
|
setMessages(resolveMessages(overrides, runtime?.events ?? []));
|
|
1233
|
-
}, [
|
|
1233
|
+
}, [store2, tenantId, runtime]);
|
|
1234
1234
|
React4__default.useEffect(() => {
|
|
1235
1235
|
void reload();
|
|
1236
1236
|
}, [reload]);
|
|
1237
|
-
const surfaceRef = React4__default.useRef({ messages, store, tenantId, reload, basePath });
|
|
1238
|
-
surfaceRef.current = { messages, store, tenantId, reload, basePath };
|
|
1237
|
+
const surfaceRef = React4__default.useRef({ messages, store: store2, tenantId, reload, basePath });
|
|
1238
|
+
surfaceRef.current = { messages, store: store2, tenantId, reload, basePath };
|
|
1239
1239
|
const agentSurface = React4__default.useMemo(() => ({
|
|
1240
1240
|
id: "automations.list",
|
|
1241
1241
|
pluginId: "automations",
|
|
@@ -1303,7 +1303,7 @@ function AutomationsList({ store, variant = "page", basePath = "/automations" })
|
|
|
1303
1303
|
const toggle = async (m, enabled) => {
|
|
1304
1304
|
setMessages((prev) => prev?.map((x) => x.event.name === m.event.name && x.channel === m.channel ? { ...x, enabled } : x) ?? null);
|
|
1305
1305
|
try {
|
|
1306
|
-
await
|
|
1306
|
+
await store2.upsert({ eventKey: m.event.name, channel: m.channel, enabled }, tenantId);
|
|
1307
1307
|
await reload();
|
|
1308
1308
|
} catch (err) {
|
|
1309
1309
|
console.warn("[automations] toggle failed", err);
|
|
@@ -1459,7 +1459,7 @@ function AutomationsList({ store, variant = "page", basePath = "/automations" })
|
|
|
1459
1459
|
{
|
|
1460
1460
|
eventKey: selected.eventKey,
|
|
1461
1461
|
channel: selected.channel,
|
|
1462
|
-
store,
|
|
1462
|
+
store: store2,
|
|
1463
1463
|
backTo: basePath,
|
|
1464
1464
|
embedded: true
|
|
1465
1465
|
}
|
|
@@ -1527,21 +1527,1492 @@ function createSupabaseTemplateStore() {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
};
|
|
1529
1529
|
}
|
|
1530
|
+
var FLOWS = "plg_automations_flows";
|
|
1531
|
+
var STEPS = "plg_automations_flow_steps";
|
|
1532
|
+
var ENROLLMENTS = "plg_automations_enrollments";
|
|
1533
|
+
var STEP_STATS = "v_automations_flow_steps";
|
|
1534
|
+
function rowToFlow(r) {
|
|
1535
|
+
return {
|
|
1536
|
+
id: r.id,
|
|
1537
|
+
tenantId: r.tenant_id,
|
|
1538
|
+
name: r.name,
|
|
1539
|
+
description: r.description ?? null,
|
|
1540
|
+
entryEvent: r.entry_event,
|
|
1541
|
+
exitEvents: Array.isArray(r.exit_events) ? r.exit_events : [],
|
|
1542
|
+
isActive: r.is_active === true,
|
|
1543
|
+
createdAt: r.created_at,
|
|
1544
|
+
updatedAt: r.updated_at ?? r.created_at
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
function rowToStepStats(r) {
|
|
1548
|
+
return {
|
|
1549
|
+
id: r.step_id ?? r.id,
|
|
1550
|
+
flowId: r.flow_id,
|
|
1551
|
+
position: r.position,
|
|
1552
|
+
delayMinutes: r.delay_minutes ?? 0,
|
|
1553
|
+
kind: r.kind === "task" ? "task" : "message",
|
|
1554
|
+
templateKey: r.template_key ?? null,
|
|
1555
|
+
channel: r.channel ?? null,
|
|
1556
|
+
taskTitle: r.task_title ?? null,
|
|
1557
|
+
taskDueDays: r.task_due_days ?? null,
|
|
1558
|
+
queued: Number(r.queued ?? 0),
|
|
1559
|
+
sent: Number(r.sent ?? 0),
|
|
1560
|
+
blocked: Number(r.blocked ?? 0),
|
|
1561
|
+
failed: Number(r.failed ?? 0),
|
|
1562
|
+
cancelled: Number(r.cancelled ?? 0)
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
function nextPosition(steps) {
|
|
1566
|
+
if (steps.length === 0) return 10;
|
|
1567
|
+
return Math.max(...steps.map((s) => s.position)) + 10;
|
|
1568
|
+
}
|
|
1569
|
+
function describeDelay(minutes, t) {
|
|
1570
|
+
if (minutes <= 0) return t("automations.flow.delayNow");
|
|
1571
|
+
if (minutes % 1440 === 0) return t("automations.flow.delayDays", { count: String(minutes / 1440) });
|
|
1572
|
+
if (minutes % 60 === 0) return t("automations.flow.delayHours", { count: String(minutes / 60) });
|
|
1573
|
+
return t("automations.flow.delayMinutes", { count: String(minutes) });
|
|
1574
|
+
}
|
|
1575
|
+
function createSupabaseFlowStore() {
|
|
1576
|
+
function client() {
|
|
1577
|
+
const supabase = getSupabaseClientOptional();
|
|
1578
|
+
if (!supabase) throw new Error("Supabase not initialized");
|
|
1579
|
+
return supabase;
|
|
1580
|
+
}
|
|
1581
|
+
const store2 = {
|
|
1582
|
+
async listFlows() {
|
|
1583
|
+
const { data, error } = await client().from(FLOWS).select("*").order("name");
|
|
1584
|
+
if (error) throw new Error(error.message);
|
|
1585
|
+
return (data ?? []).map(rowToFlow);
|
|
1586
|
+
},
|
|
1587
|
+
async saveFlow(input) {
|
|
1588
|
+
const row = {
|
|
1589
|
+
name: input.name,
|
|
1590
|
+
description: input.description ?? null,
|
|
1591
|
+
entry_event: input.entryEvent,
|
|
1592
|
+
exit_events: input.exitEvents ?? []
|
|
1593
|
+
};
|
|
1594
|
+
if (input.id) {
|
|
1595
|
+
const { data: data2, error: error2 } = await client().from(FLOWS).update(row).eq("id", input.id).select().single();
|
|
1596
|
+
if (error2) throw new Error(error2.message);
|
|
1597
|
+
return rowToFlow(data2);
|
|
1598
|
+
}
|
|
1599
|
+
row.tenant_id = getActiveTenantId();
|
|
1600
|
+
const { data, error } = await client().from(FLOWS).insert(row).select().single();
|
|
1601
|
+
if (error) throw new Error(error.message);
|
|
1602
|
+
return rowToFlow(data);
|
|
1603
|
+
},
|
|
1604
|
+
async setFlowActive(id, isActive) {
|
|
1605
|
+
const { error } = await client().from(FLOWS).update({ is_active: isActive, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
|
|
1606
|
+
if (error) throw new Error(error.message);
|
|
1607
|
+
},
|
|
1608
|
+
async deleteFlow(id) {
|
|
1609
|
+
const { error } = await client().from(FLOWS).delete().eq("id", id);
|
|
1610
|
+
if (error) throw new Error(error.message);
|
|
1611
|
+
},
|
|
1612
|
+
async listSteps(flowId) {
|
|
1613
|
+
const { data, error } = await client().from(STEP_STATS).select("*").eq("flow_id", flowId).order("position");
|
|
1614
|
+
if (error) throw new Error(error.message);
|
|
1615
|
+
return (data ?? []).map(rowToStepStats);
|
|
1616
|
+
},
|
|
1617
|
+
async saveStep(flowId, input) {
|
|
1618
|
+
const row = {
|
|
1619
|
+
flow_id: flowId,
|
|
1620
|
+
position: input.position,
|
|
1621
|
+
delay_minutes: input.delayMinutes ?? 0,
|
|
1622
|
+
kind: input.kind ?? "message",
|
|
1623
|
+
template_key: input.kind === "task" ? null : input.templateKey ?? null,
|
|
1624
|
+
channel: input.kind === "task" ? null : input.channel ?? null,
|
|
1625
|
+
task_title: input.kind === "task" ? input.taskTitle ?? null : null,
|
|
1626
|
+
task_due_days: input.kind === "task" ? input.taskDueDays ?? null : null
|
|
1627
|
+
};
|
|
1628
|
+
if (input.id) {
|
|
1629
|
+
const { error: error2 } = await client().from(STEPS).update(row).eq("id", input.id);
|
|
1630
|
+
if (error2) throw new Error(error2.message);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
row.tenant_id = getActiveTenantId();
|
|
1634
|
+
const { error } = await client().from(STEPS).insert(row);
|
|
1635
|
+
if (error) throw new Error(error.message);
|
|
1636
|
+
},
|
|
1637
|
+
async deleteStep(id) {
|
|
1638
|
+
const { error } = await client().from(STEPS).delete().eq("id", id);
|
|
1639
|
+
if (error) throw new Error(error.message);
|
|
1640
|
+
},
|
|
1641
|
+
async countsFor(flowId) {
|
|
1642
|
+
const { data, error } = await client().from(ENROLLMENTS).select("status").eq("flow_id", flowId);
|
|
1643
|
+
if (error) throw new Error(error.message);
|
|
1644
|
+
const rows = data ?? [];
|
|
1645
|
+
return {
|
|
1646
|
+
active: rows.filter((r) => r.status === "active").length,
|
|
1647
|
+
completed: rows.filter((r) => r.status === "completed").length,
|
|
1648
|
+
exited: rows.filter((r) => r.status === "exited").length
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
return store2;
|
|
1653
|
+
}
|
|
1654
|
+
var store = createSupabaseFlowStore();
|
|
1655
|
+
var DELAY_CHOICES = [0, 60, 1440, 2880, 4320, 10080];
|
|
1656
|
+
function useStepColumns(onDelete) {
|
|
1657
|
+
const t = useTranslation();
|
|
1658
|
+
return useMemo(() => [
|
|
1659
|
+
{
|
|
1660
|
+
accessorKey: "position",
|
|
1661
|
+
header: "#",
|
|
1662
|
+
cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs tabular-nums text-muted-foreground", children: getValue() })
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
id: "step",
|
|
1666
|
+
header: t("automations.flow.colStep"),
|
|
1667
|
+
cell: ({ row }) => {
|
|
1668
|
+
const s = row.original;
|
|
1669
|
+
const isTask = s.kind === "task";
|
|
1670
|
+
return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5 text-sm", children: [
|
|
1671
|
+
isTask ? /* @__PURE__ */ jsx(ListChecks, { className: "h-3.5 w-3.5 text-muted-foreground" }) : /* @__PURE__ */ jsx(MessageSquare, { className: "h-3.5 w-3.5 text-muted-foreground" }),
|
|
1672
|
+
isTask ? s.taskTitle : s.templateKey,
|
|
1673
|
+
!isTask && s.channel && /* @__PURE__ */ jsx("span", { className: "rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground", children: s.channel })
|
|
1674
|
+
] });
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
accessorKey: "delayMinutes",
|
|
1679
|
+
header: t("automations.flow.colWait"),
|
|
1680
|
+
cell: ({ getValue }) => /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-muted-foreground", children: [
|
|
1681
|
+
/* @__PURE__ */ jsx(Clock, { className: "h-3 w-3" }),
|
|
1682
|
+
describeDelay(getValue(), t)
|
|
1683
|
+
] })
|
|
1684
|
+
},
|
|
1685
|
+
// The scoreboard. Zeroes are shown rather than hidden: a step that has never
|
|
1686
|
+
// sent is the finding, not an empty cell.
|
|
1687
|
+
{
|
|
1688
|
+
accessorKey: "queued",
|
|
1689
|
+
header: t("automations.flow.colQueued"),
|
|
1690
|
+
cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs tabular-nums", children: getValue() })
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
accessorKey: "sent",
|
|
1694
|
+
header: t("automations.flow.colSent"),
|
|
1695
|
+
cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs tabular-nums", children: getValue() })
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
accessorKey: "blocked",
|
|
1699
|
+
header: t("automations.flow.colBlocked"),
|
|
1700
|
+
cell: ({ getValue }) => {
|
|
1701
|
+
const n = getValue();
|
|
1702
|
+
return /* @__PURE__ */ jsx("span", { className: `text-xs tabular-nums ${n > 0 ? "text-warning" : ""}`, children: n });
|
|
1703
|
+
}
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
accessorKey: "failed",
|
|
1707
|
+
header: t("automations.flow.colFailed"),
|
|
1708
|
+
cell: ({ getValue }) => {
|
|
1709
|
+
const n = getValue();
|
|
1710
|
+
return /* @__PURE__ */ jsx("span", { className: `text-xs tabular-nums ${n > 0 ? "text-destructive" : ""}`, children: n });
|
|
1711
|
+
}
|
|
1712
|
+
},
|
|
1713
|
+
{
|
|
1714
|
+
id: "actions",
|
|
1715
|
+
header: "",
|
|
1716
|
+
cell: ({ row }) => /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
|
|
1717
|
+
"button",
|
|
1718
|
+
{
|
|
1719
|
+
onClick: (e) => {
|
|
1720
|
+
e.stopPropagation();
|
|
1721
|
+
onDelete(row.original.id);
|
|
1722
|
+
},
|
|
1723
|
+
title: t("automations.flow.deleteStep"),
|
|
1724
|
+
className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-destructive",
|
|
1725
|
+
children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
|
|
1726
|
+
}
|
|
1727
|
+
) })
|
|
1728
|
+
}
|
|
1729
|
+
], [t, onDelete]);
|
|
1730
|
+
}
|
|
1731
|
+
function StepComposer({ steps, onAdd }) {
|
|
1732
|
+
const t = useTranslation();
|
|
1733
|
+
const [kind, setKind] = useState("message");
|
|
1734
|
+
const [delay, setDelay] = useState(1440);
|
|
1735
|
+
const [value, setValue] = useState("");
|
|
1736
|
+
const submit = () => {
|
|
1737
|
+
if (!value.trim()) {
|
|
1738
|
+
toast.error(t(kind === "task" ? "automations.flow.needTitle" : "automations.flow.needTemplate"));
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
onAdd({
|
|
1742
|
+
kind,
|
|
1743
|
+
delayMinutes: delay,
|
|
1744
|
+
position: nextPosition(steps),
|
|
1745
|
+
...kind === "task" ? { taskTitle: value.trim() } : { templateKey: value.trim() }
|
|
1746
|
+
});
|
|
1747
|
+
setValue("");
|
|
1748
|
+
};
|
|
1749
|
+
return /* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-dashed p-3", children: [
|
|
1750
|
+
/* @__PURE__ */ jsxs(
|
|
1751
|
+
"select",
|
|
1752
|
+
{
|
|
1753
|
+
value: kind,
|
|
1754
|
+
onChange: (e) => {
|
|
1755
|
+
setKind(e.target.value);
|
|
1756
|
+
setValue("");
|
|
1757
|
+
},
|
|
1758
|
+
className: "rounded-md border bg-background px-2 py-1.5 text-sm",
|
|
1759
|
+
children: [
|
|
1760
|
+
/* @__PURE__ */ jsx("option", { value: "message", children: t("automations.flow.kindMessage") }),
|
|
1761
|
+
/* @__PURE__ */ jsx("option", { value: "task", children: t("automations.flow.kindTask") })
|
|
1762
|
+
]
|
|
1763
|
+
}
|
|
1764
|
+
),
|
|
1765
|
+
/* @__PURE__ */ jsx(
|
|
1766
|
+
"select",
|
|
1767
|
+
{
|
|
1768
|
+
value: delay,
|
|
1769
|
+
onChange: (e) => setDelay(Number(e.target.value)),
|
|
1770
|
+
className: "rounded-md border bg-background px-2 py-1.5 text-sm",
|
|
1771
|
+
children: DELAY_CHOICES.map((m) => /* @__PURE__ */ jsx("option", { value: m, children: describeDelay(m, t) }, m))
|
|
1772
|
+
}
|
|
1773
|
+
),
|
|
1774
|
+
/* @__PURE__ */ jsx(
|
|
1775
|
+
"input",
|
|
1776
|
+
{
|
|
1777
|
+
value,
|
|
1778
|
+
onChange: (e) => setValue(e.target.value),
|
|
1779
|
+
onKeyDown: (e) => {
|
|
1780
|
+
if (e.key === "Enter") submit();
|
|
1781
|
+
},
|
|
1782
|
+
placeholder: t(kind === "task" ? "automations.flow.titlePlaceholder" : "automations.flow.templatePlaceholder"),
|
|
1783
|
+
className: "min-w-[200px] flex-1 rounded-md border bg-background px-2 py-1.5 text-sm"
|
|
1784
|
+
}
|
|
1785
|
+
),
|
|
1786
|
+
/* @__PURE__ */ jsxs(
|
|
1787
|
+
"button",
|
|
1788
|
+
{
|
|
1789
|
+
onClick: submit,
|
|
1790
|
+
className: "inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90",
|
|
1791
|
+
children: [
|
|
1792
|
+
/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }),
|
|
1793
|
+
t("automations.flow.addStep")
|
|
1794
|
+
]
|
|
1795
|
+
}
|
|
1796
|
+
)
|
|
1797
|
+
] });
|
|
1798
|
+
}
|
|
1799
|
+
function FlowsPage() {
|
|
1800
|
+
const t = useTranslation();
|
|
1801
|
+
const [flows, setFlows] = useState([]);
|
|
1802
|
+
const [loading, setLoading] = useState(true);
|
|
1803
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
1804
|
+
const [steps, setSteps] = useState([]);
|
|
1805
|
+
const [counts, setCounts] = useState(null);
|
|
1806
|
+
const load = useCallback(async () => {
|
|
1807
|
+
setLoading(true);
|
|
1808
|
+
try {
|
|
1809
|
+
setFlows(await store.listFlows());
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
toast.error(errorMessage(e));
|
|
1812
|
+
} finally {
|
|
1813
|
+
setLoading(false);
|
|
1814
|
+
}
|
|
1815
|
+
}, []);
|
|
1816
|
+
const selectedRef = useRef(null);
|
|
1817
|
+
selectedRef.current = selectedId;
|
|
1818
|
+
const loadDetail = useCallback(async (flowId) => {
|
|
1819
|
+
try {
|
|
1820
|
+
const [s, c] = await Promise.all([store.listSteps(flowId), store.countsFor(flowId)]);
|
|
1821
|
+
if (selectedRef.current !== flowId) return;
|
|
1822
|
+
setSteps(s);
|
|
1823
|
+
setCounts(c);
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
toast.error(errorMessage(e));
|
|
1826
|
+
}
|
|
1827
|
+
}, []);
|
|
1828
|
+
const deleteStep = useCallback((id) => {
|
|
1829
|
+
void (async () => {
|
|
1830
|
+
try {
|
|
1831
|
+
await store.deleteStep(id);
|
|
1832
|
+
if (selectedId) await loadDetail(selectedId);
|
|
1833
|
+
} catch (e) {
|
|
1834
|
+
toast.error(errorMessage(e));
|
|
1835
|
+
}
|
|
1836
|
+
})();
|
|
1837
|
+
}, [selectedId, loadDetail]);
|
|
1838
|
+
const stepColumns = useStepColumns(deleteStep);
|
|
1839
|
+
useEffect(() => {
|
|
1840
|
+
void load();
|
|
1841
|
+
}, [load]);
|
|
1842
|
+
useEffect(() => {
|
|
1843
|
+
setSteps([]);
|
|
1844
|
+
setCounts(null);
|
|
1845
|
+
if (selectedId) void loadDetail(selectedId);
|
|
1846
|
+
}, [selectedId, loadDetail]);
|
|
1847
|
+
flows.find((f) => f.id === selectedId) ?? null;
|
|
1848
|
+
async function createFlow() {
|
|
1849
|
+
const name = window.prompt(t("automations.flow.namePrompt"));
|
|
1850
|
+
if (!name?.trim()) return;
|
|
1851
|
+
const entryEvent = window.prompt(t("automations.flow.entryPrompt"), "crm.lead_created");
|
|
1852
|
+
if (!entryEvent?.trim()) return;
|
|
1853
|
+
try {
|
|
1854
|
+
const flow = await store.saveFlow({ name: name.trim(), entryEvent: entryEvent.trim() });
|
|
1855
|
+
await load();
|
|
1856
|
+
setSelectedId(flow.id);
|
|
1857
|
+
toast.success(t("automations.flow.created"));
|
|
1858
|
+
} catch (e) {
|
|
1859
|
+
toast.error(errorMessage(e));
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function toggleActive(flow) {
|
|
1863
|
+
if (!flow.isActive) {
|
|
1864
|
+
const s = await store.listSteps(flow.id);
|
|
1865
|
+
if (s.length === 0) {
|
|
1866
|
+
toast.error(t("automations.flow.needStepsToActivate"));
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
try {
|
|
1871
|
+
await store.setFlowActive(flow.id, !flow.isActive);
|
|
1872
|
+
await load();
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
toast.error(errorMessage(e));
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (loading) {
|
|
1878
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 p-6 text-sm text-muted-foreground", children: [
|
|
1879
|
+
/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
|
|
1880
|
+
t("automations.flow.loading")
|
|
1881
|
+
] });
|
|
1882
|
+
}
|
|
1883
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
1884
|
+
/* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
|
|
1885
|
+
PageActions,
|
|
1886
|
+
{
|
|
1887
|
+
actions: [{
|
|
1888
|
+
id: "new-flow",
|
|
1889
|
+
label: t("automations.flow.new"),
|
|
1890
|
+
icon: Plus,
|
|
1891
|
+
intent: "primary",
|
|
1892
|
+
creates: true,
|
|
1893
|
+
onSelect: () => {
|
|
1894
|
+
void createFlow();
|
|
1895
|
+
}
|
|
1896
|
+
}]
|
|
1897
|
+
}
|
|
1898
|
+
) }),
|
|
1899
|
+
flows.length === 0 ? /* @__PURE__ */ jsxs(Card, { className: "flex flex-col items-center gap-1 border-dashed py-12 text-center", children: [
|
|
1900
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: t("automations.flow.emptyTitle") }),
|
|
1901
|
+
/* @__PURE__ */ jsx("p", { className: "max-w-md text-xs text-muted-foreground", children: t("automations.flow.emptyHint") })
|
|
1902
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "space-y-2", children: flows.map((flow) => /* @__PURE__ */ jsxs(Card, { className: "p-3", children: [
|
|
1903
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
|
|
1904
|
+
/* @__PURE__ */ jsxs(
|
|
1905
|
+
"button",
|
|
1906
|
+
{
|
|
1907
|
+
onClick: () => setSelectedId(selectedId === flow.id ? null : flow.id),
|
|
1908
|
+
className: "flex flex-1 items-center gap-2 text-left",
|
|
1909
|
+
children: [
|
|
1910
|
+
/* @__PURE__ */ jsx(
|
|
1911
|
+
ChevronRight,
|
|
1912
|
+
{
|
|
1913
|
+
className: `h-4 w-4 text-muted-foreground transition-transform ${selectedId === flow.id ? "rotate-90" : ""}`
|
|
1914
|
+
}
|
|
1915
|
+
),
|
|
1916
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: flow.name }),
|
|
1917
|
+
/* @__PURE__ */ jsx("span", { className: "rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground", children: flow.entryEvent })
|
|
1918
|
+
]
|
|
1919
|
+
}
|
|
1920
|
+
),
|
|
1921
|
+
/* @__PURE__ */ jsx(
|
|
1922
|
+
Switch,
|
|
1923
|
+
{
|
|
1924
|
+
checked: flow.isActive,
|
|
1925
|
+
onCheckedChange: () => {
|
|
1926
|
+
void toggleActive(flow);
|
|
1927
|
+
},
|
|
1928
|
+
"aria-label": t("automations.flow.toggle")
|
|
1929
|
+
}
|
|
1930
|
+
)
|
|
1931
|
+
] }),
|
|
1932
|
+
selectedId === flow.id && /* @__PURE__ */ jsxs("div", { className: "mt-3 border-t pt-3", children: [
|
|
1933
|
+
counts && /* @__PURE__ */ jsx("p", { className: "mb-2 text-xs text-muted-foreground", children: t("automations.flow.counts", {
|
|
1934
|
+
active: String(counts.active),
|
|
1935
|
+
completed: String(counts.completed),
|
|
1936
|
+
exited: String(counts.exited)
|
|
1937
|
+
}) }),
|
|
1938
|
+
/* @__PURE__ */ jsx(
|
|
1939
|
+
DataTable,
|
|
1940
|
+
{
|
|
1941
|
+
columns: stepColumns,
|
|
1942
|
+
data: steps,
|
|
1943
|
+
emptyMessage: /* @__PURE__ */ jsx("p", { className: "py-4 text-xs text-muted-foreground", children: t("automations.flow.noSteps") })
|
|
1944
|
+
}
|
|
1945
|
+
),
|
|
1946
|
+
/* @__PURE__ */ jsx(
|
|
1947
|
+
StepComposer,
|
|
1948
|
+
{
|
|
1949
|
+
steps,
|
|
1950
|
+
onAdd: (input) => {
|
|
1951
|
+
void (async () => {
|
|
1952
|
+
try {
|
|
1953
|
+
await store.saveStep(flow.id, input);
|
|
1954
|
+
await loadDetail(flow.id);
|
|
1955
|
+
} catch (e) {
|
|
1956
|
+
toast.error(errorMessage(e));
|
|
1957
|
+
}
|
|
1958
|
+
})();
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
)
|
|
1962
|
+
] })
|
|
1963
|
+
] }, flow.id)) })
|
|
1964
|
+
] });
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// src/locales/en.ts
|
|
1968
|
+
var en = {
|
|
1969
|
+
"automations.flow.new": "New flow",
|
|
1970
|
+
"automations.flow.loading": "Loading flows\u2026",
|
|
1971
|
+
"automations.flow.emptyTitle": "No cadence flows yet",
|
|
1972
|
+
"automations.flow.emptyHint": "A flow touches the same lead more than once \u2014 a message today, a call in two days, another message on Friday \u2014 and stops on its own when they answer.",
|
|
1973
|
+
"automations.flow.namePrompt": "Name this flow",
|
|
1974
|
+
"automations.flow.entryPrompt": "Which event puts someone in?",
|
|
1975
|
+
"automations.flow.created": "Flow created. It starts switched off.",
|
|
1976
|
+
"automations.flow.toggle": "Active",
|
|
1977
|
+
"automations.flow.needStepsToActivate": "Add a step first \u2014 an empty flow enrols people into nothing.",
|
|
1978
|
+
"automations.flow.noSteps": "No steps yet.",
|
|
1979
|
+
"automations.flow.counts": "{{active}} in the flow \xB7 {{completed}} finished \xB7 {{exited}} left early",
|
|
1980
|
+
"automations.flow.colStep": "Step",
|
|
1981
|
+
"automations.flow.colWait": "Wait",
|
|
1982
|
+
"automations.flow.colQueued": "Queued",
|
|
1983
|
+
"automations.flow.colSent": "Sent",
|
|
1984
|
+
"automations.flow.colBlocked": "Blocked",
|
|
1985
|
+
"automations.flow.colFailed": "Failed",
|
|
1986
|
+
"automations.flow.addStep": "Add step",
|
|
1987
|
+
"automations.flow.deleteStep": "Remove step",
|
|
1988
|
+
"automations.flow.kindMessage": "Message",
|
|
1989
|
+
"automations.flow.kindTask": "Task",
|
|
1990
|
+
"automations.flow.templatePlaceholder": "Template key, e.g. lead.followup",
|
|
1991
|
+
"automations.flow.titlePlaceholder": "What should somebody do?",
|
|
1992
|
+
"automations.flow.needTemplate": "A message step needs a template.",
|
|
1993
|
+
"automations.flow.needTitle": "A task step needs a title.",
|
|
1994
|
+
"automations.flow.delayNow": "Right away",
|
|
1995
|
+
"automations.flow.delayMinutes": "{{count}} min later",
|
|
1996
|
+
"automations.flow.delayHours": "{{count}}h later",
|
|
1997
|
+
"automations.flow.delayDays": "{{count}} days later"
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
// src/locales/pt-BR.ts
|
|
2001
|
+
var ptBR = {
|
|
2002
|
+
"automations.flow.new": "Novo fluxo",
|
|
2003
|
+
"automations.flow.loading": "Carregando fluxos\u2026",
|
|
2004
|
+
"automations.flow.emptyTitle": "Nenhum fluxo de cad\xEAncia ainda",
|
|
2005
|
+
"automations.flow.emptyHint": "Um fluxo toca o mesmo lead mais de uma vez \u2014 uma mensagem hoje, uma liga\xE7\xE3o em dois dias, outra mensagem na sexta \u2014 e para sozinho quando a pessoa responde.",
|
|
2006
|
+
"automations.flow.namePrompt": "Nome do fluxo",
|
|
2007
|
+
"automations.flow.entryPrompt": "Qual evento coloca algu\xE9m aqui?",
|
|
2008
|
+
"automations.flow.created": "Fluxo criado. Ele nasce desligado.",
|
|
2009
|
+
"automations.flow.toggle": "Ativo",
|
|
2010
|
+
"automations.flow.needStepsToActivate": "Adicione um passo antes \u2014 um fluxo vazio inscreve gente em nada.",
|
|
2011
|
+
"automations.flow.noSteps": "Nenhum passo ainda.",
|
|
2012
|
+
"automations.flow.counts": "{{active}} no fluxo \xB7 {{completed}} conclu\xEDram \xB7 {{exited}} sa\xEDram antes",
|
|
2013
|
+
"automations.flow.colStep": "Passo",
|
|
2014
|
+
"automations.flow.colWait": "Espera",
|
|
2015
|
+
"automations.flow.colQueued": "Na fila",
|
|
2016
|
+
"automations.flow.colSent": "Enviados",
|
|
2017
|
+
"automations.flow.colBlocked": "Bloqueados",
|
|
2018
|
+
"automations.flow.colFailed": "Falharam",
|
|
2019
|
+
"automations.flow.addStep": "Adicionar passo",
|
|
2020
|
+
"automations.flow.deleteStep": "Remover passo",
|
|
2021
|
+
"automations.flow.kindMessage": "Mensagem",
|
|
2022
|
+
"automations.flow.kindTask": "Tarefa",
|
|
2023
|
+
"automations.flow.templatePlaceholder": "Chave do modelo, ex.: lead.followup",
|
|
2024
|
+
"automations.flow.titlePlaceholder": "O que algu\xE9m precisa fazer?",
|
|
2025
|
+
"automations.flow.needTemplate": "Um passo de mensagem precisa de um modelo.",
|
|
2026
|
+
"automations.flow.needTitle": "Um passo de tarefa precisa de um t\xEDtulo.",
|
|
2027
|
+
"automations.flow.delayNow": "Na hora",
|
|
2028
|
+
"automations.flow.delayMinutes": "{{count}} min depois",
|
|
2029
|
+
"automations.flow.delayHours": "{{count}}h depois",
|
|
2030
|
+
"automations.flow.delayDays": "{{count}} dias depois"
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
// src/locales/index.ts
|
|
2034
|
+
var automationsLocales = {
|
|
2035
|
+
en,
|
|
2036
|
+
"pt-BR": ptBR
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
// src/migrations/index.ts
|
|
2040
|
+
var MIGRATION_000_BASELINE = `-- ============================================================================
|
|
2041
|
+
-- plugins/plugin-automations/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
|
|
2042
|
+
--
|
|
2043
|
+
-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
|
|
2044
|
+
-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
|
|
2045
|
+
-- prove the result still matches.
|
|
2046
|
+
--
|
|
2047
|
+
-- This replaced 4 migration files. They are kept, unapplied, in
|
|
2048
|
+
-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
|
|
2049
|
+
-- longer worth replaying.
|
|
2050
|
+
--
|
|
2051
|
+
-- Object order is fixed rather than dependency-sorted: schemas, types,
|
|
2052
|
+
-- sequences, tables, defaults, functions, constraints, views, indexes, foreign
|
|
2053
|
+
-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
|
|
2054
|
+
-- are carried by the order packages/db/chain.json declares.
|
|
2055
|
+
-- ============================================================================
|
|
2056
|
+
|
|
2057
|
+
-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
|
|
2058
|
+
-- sorted by name are not sorted by who calls whom. This is what lets them load
|
|
2059
|
+
-- in any order; every body is still compiled on first call, so a genuinely
|
|
2060
|
+
-- broken reference surfaces then rather than never. Session-scoped.
|
|
2061
|
+
SET check_function_bodies = false;
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
-- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2066
|
+
-- Cleared BEFORE anything is created, so every object below gets the ACL the
|
|
2067
|
+
-- dump describes rather than the image's defaults on top of it. Restored at
|
|
2068
|
+
-- the end of this file.
|
|
2069
|
+
|
|
2070
|
+
DO $dp$ BEGIN
|
|
2071
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2072
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2073
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2074
|
+
END $dp$;
|
|
2075
|
+
|
|
2076
|
+
DO $dp$ BEGIN
|
|
2077
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2078
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2079
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2080
|
+
END $dp$;
|
|
2081
|
+
|
|
2082
|
+
DO $dp$ BEGIN
|
|
2083
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2084
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2085
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2086
|
+
END $dp$;
|
|
2087
|
+
|
|
2088
|
+
DO $dp$ BEGIN
|
|
2089
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2090
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2091
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2092
|
+
END $dp$;
|
|
2093
|
+
|
|
2094
|
+
DO $dp$ BEGIN
|
|
2095
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2096
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2097
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2098
|
+
END $dp$;
|
|
2099
|
+
|
|
2100
|
+
DO $dp$ BEGIN
|
|
2101
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2102
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2103
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
2104
|
+
END $dp$;
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
-- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2108
|
+
|
|
2109
|
+
CREATE TABLE public.plg_automations_provider_templates (
|
|
2110
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
2111
|
+
tenant_id uuid,
|
|
2112
|
+
event_key text NOT NULL,
|
|
2113
|
+
channel text DEFAULT 'whatsapp'::text NOT NULL,
|
|
2114
|
+
provider text DEFAULT 'tyxter'::text NOT NULL,
|
|
2115
|
+
provider_template_id text,
|
|
2116
|
+
template_name text NOT NULL,
|
|
2117
|
+
language text DEFAULT 'pt_BR'::text NOT NULL,
|
|
2118
|
+
variable_map jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
2119
|
+
actions_map jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
2120
|
+
status text DEFAULT 'draft'::text NOT NULL,
|
|
2121
|
+
rejection_reason text,
|
|
2122
|
+
body_snapshot text,
|
|
2123
|
+
submitted_at timestamp with time zone,
|
|
2124
|
+
approved_at timestamp with time zone,
|
|
2125
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
2126
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
2127
|
+
CONSTRAINT plg_automations_provider_templates_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'submitted'::text, 'approved'::text, 'rejected'::text, 'paused'::text, 'disabled'::text])))
|
|
2128
|
+
);
|
|
2129
|
+
|
|
2130
|
+
CREATE TABLE public.plg_automations_templates (
|
|
2131
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
2132
|
+
tenant_id uuid NOT NULL,
|
|
2133
|
+
event_key text NOT NULL,
|
|
2134
|
+
channel text DEFAULT 'email'::text NOT NULL,
|
|
2135
|
+
enabled boolean,
|
|
2136
|
+
subject text,
|
|
2137
|
+
body text,
|
|
2138
|
+
name text,
|
|
2139
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
2140
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
2141
|
+
actions jsonb
|
|
2142
|
+
);
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
-- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2146
|
+
|
|
2147
|
+
CREATE FUNCTION public.automations_set_updated_at() RETURNS trigger
|
|
2148
|
+
LANGUAGE plpgsql
|
|
2149
|
+
AS $$
|
|
2150
|
+
BEGIN
|
|
2151
|
+
NEW.updated_at = now();
|
|
2152
|
+
RETURN NEW;
|
|
2153
|
+
END $$;
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
-- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2157
|
+
|
|
2158
|
+
ALTER TABLE ONLY public.plg_automations_provider_templates
|
|
2159
|
+
ADD CONSTRAINT plg_automations_provider_templates_pkey PRIMARY KEY (id);
|
|
2160
|
+
|
|
2161
|
+
ALTER TABLE ONLY public.plg_automations_templates
|
|
2162
|
+
ADD CONSTRAINT plg_automations_templates_pkey PRIMARY KEY (id);
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
-- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2166
|
+
|
|
2167
|
+
CREATE INDEX idx_plg_automations_provider_templates_lookup ON public.plg_automations_provider_templates USING btree (event_key, channel, provider, status);
|
|
2168
|
+
|
|
2169
|
+
CREATE INDEX plg_automations_templates_tenant_idx ON public.plg_automations_templates USING btree (tenant_id);
|
|
2170
|
+
|
|
2171
|
+
CREATE UNIQUE INDEX plg_automations_templates_unique_idx ON public.plg_automations_templates USING btree (tenant_id, event_key, channel);
|
|
2172
|
+
|
|
2173
|
+
CREATE UNIQUE INDEX uq_plg_automations_provider_templates_owner ON public.plg_automations_provider_templates USING btree (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), event_key, channel, provider);
|
|
2174
|
+
|
|
2175
|
+
CREATE UNIQUE INDEX uq_plg_automations_provider_templates_provider_id ON public.plg_automations_provider_templates USING btree (provider, provider_template_id) WHERE (provider_template_id IS NOT NULL);
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
-- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2179
|
+
|
|
2180
|
+
ALTER TABLE ONLY public.plg_automations_provider_templates
|
|
2181
|
+
ADD CONSTRAINT plg_automations_provider_templates_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
-- \u2500\u2500 trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2185
|
+
|
|
2186
|
+
CREATE TRIGGER plg_automations_provider_templates_set_updated_at BEFORE UPDATE ON public.plg_automations_provider_templates FOR EACH ROW EXECUTE FUNCTION public.automations_set_updated_at();
|
|
2187
|
+
|
|
2188
|
+
CREATE TRIGGER plg_automations_templates_set_updated_at BEFORE UPDATE ON public.plg_automations_templates FOR EACH ROW EXECUTE FUNCTION public.automations_set_updated_at();
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
-- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2192
|
+
|
|
2193
|
+
ALTER TABLE public.plg_automations_provider_templates ENABLE ROW LEVEL SECURITY;
|
|
2194
|
+
|
|
2195
|
+
ALTER TABLE public.plg_automations_templates ENABLE ROW LEVEL SECURITY;
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
-- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2199
|
+
|
|
2200
|
+
CREATE POLICY plg_automations_provider_templates_select ON public.plg_automations_provider_templates FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));
|
|
2201
|
+
|
|
2202
|
+
CREATE POLICY plg_automations_templates_member_all ON public.plg_automations_templates TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))) WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
-- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2206
|
+
|
|
2207
|
+
GRANT ALL ON FUNCTION public.automations_set_updated_at() TO authenticated;
|
|
2208
|
+
GRANT ALL ON FUNCTION public.automations_set_updated_at() TO service_role;
|
|
2209
|
+
|
|
2210
|
+
GRANT MAINTAIN ON TABLE public.plg_automations_provider_templates TO anon;
|
|
2211
|
+
GRANT ALL ON TABLE public.plg_automations_provider_templates TO authenticated;
|
|
2212
|
+
GRANT ALL ON TABLE public.plg_automations_provider_templates TO service_role;
|
|
2213
|
+
|
|
2214
|
+
GRANT MAINTAIN ON TABLE public.plg_automations_templates TO anon;
|
|
2215
|
+
GRANT ALL ON TABLE public.plg_automations_templates TO authenticated;
|
|
2216
|
+
GRANT ALL ON TABLE public.plg_automations_templates TO service_role;
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
-- \u2500\u2500 comment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2220
|
+
|
|
2221
|
+
COMMENT ON COLUMN public.plg_automations_templates.actions IS 'Channel-neutral message actions: [{id: confirm|cancel|pay, label, kind: reply|payment}]. NULL = the catalog default; [] = deliberately no buttons.';
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
-- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2225
|
+
-- Put back, so the NEXT thing installed inherits what the chain had.
|
|
2226
|
+
|
|
2227
|
+
DO $dp$ BEGIN
|
|
2228
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
2229
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2230
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
2231
|
+
END $dp$;
|
|
2232
|
+
|
|
2233
|
+
DO $dp$ BEGIN
|
|
2234
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
2235
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2236
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
2237
|
+
END $dp$;
|
|
2238
|
+
|
|
2239
|
+
DO $dp$ BEGIN
|
|
2240
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
2241
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2242
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
2243
|
+
END $dp$;
|
|
2244
|
+
|
|
2245
|
+
DO $dp$ BEGIN
|
|
2246
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
2247
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2248
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
2249
|
+
END $dp$;
|
|
2250
|
+
|
|
2251
|
+
DO $dp$ BEGIN
|
|
2252
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
2253
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2254
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
2255
|
+
END $dp$;
|
|
2256
|
+
|
|
2257
|
+
DO $dp$ BEGIN
|
|
2258
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
2259
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2260
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
2261
|
+
END $dp$;
|
|
2262
|
+
|
|
2263
|
+
DO $dp$ BEGIN
|
|
2264
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
2265
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2266
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
2267
|
+
END $dp$;
|
|
2268
|
+
|
|
2269
|
+
DO $dp$ BEGIN
|
|
2270
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
2271
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2272
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
2273
|
+
END $dp$;
|
|
2274
|
+
|
|
2275
|
+
DO $dp$ BEGIN
|
|
2276
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
2277
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2278
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
2279
|
+
END $dp$;
|
|
2280
|
+
|
|
2281
|
+
DO $dp$ BEGIN
|
|
2282
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
2283
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2284
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
2285
|
+
END $dp$;
|
|
2286
|
+
|
|
2287
|
+
DO $dp$ BEGIN
|
|
2288
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
2289
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2290
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
2291
|
+
END $dp$;
|
|
2292
|
+
|
|
2293
|
+
DO $dp$ BEGIN
|
|
2294
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
2295
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2296
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
2297
|
+
END $dp$;
|
|
2298
|
+
|
|
2299
|
+
DO $dp$ BEGIN
|
|
2300
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
|
|
2301
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2302
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
|
|
2303
|
+
END $dp$;
|
|
2304
|
+
|
|
2305
|
+
DO $dp$ BEGIN
|
|
2306
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
2307
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2308
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
2309
|
+
END $dp$;
|
|
2310
|
+
|
|
2311
|
+
DO $dp$ BEGIN
|
|
2312
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
2313
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2314
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
2315
|
+
END $dp$;
|
|
2316
|
+
|
|
2317
|
+
DO $dp$ BEGIN
|
|
2318
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
2319
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2320
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
2321
|
+
END $dp$;
|
|
2322
|
+
|
|
2323
|
+
DO $dp$ BEGIN
|
|
2324
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
|
|
2325
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2326
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
|
|
2327
|
+
END $dp$;
|
|
2328
|
+
|
|
2329
|
+
DO $dp$ BEGIN
|
|
2330
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
2331
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2332
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
2333
|
+
END $dp$;
|
|
2334
|
+
|
|
2335
|
+
DO $dp$ BEGIN
|
|
2336
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
2337
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2338
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
2339
|
+
END $dp$;
|
|
2340
|
+
|
|
2341
|
+
DO $dp$ BEGIN
|
|
2342
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
2343
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2344
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
2345
|
+
END $dp$;
|
|
2346
|
+
|
|
2347
|
+
DO $dp$ BEGIN
|
|
2348
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
|
|
2349
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2350
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
|
|
2351
|
+
END $dp$;
|
|
2352
|
+
|
|
2353
|
+
DO $dp$ BEGIN
|
|
2354
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
2355
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2356
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
2357
|
+
END $dp$;
|
|
2358
|
+
|
|
2359
|
+
DO $dp$ BEGIN
|
|
2360
|
+
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
2361
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2362
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
2363
|
+
END $dp$;
|
|
2364
|
+
|
|
2365
|
+
DO $dp$ BEGIN
|
|
2366
|
+
EXECUTE $stmt$--
|
|
2367
|
+
-- PostgreSQL database dump complete
|
|
2368
|
+
--$stmt$;
|
|
2369
|
+
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
2370
|
+
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
|
|
2371
|
+
-- PostgreSQL database dump complete
|
|
2372
|
+
--$stmt$;
|
|
2373
|
+
END $dp$;
|
|
2374
|
+
`;
|
|
2375
|
+
var MIGRATION_001_THE_FLOW_HAS_STEPS = `-- 001_the_flow_has_steps.sql
|
|
2376
|
+
--
|
|
2377
|
+
-- A CADENCE: the same lead touched on day 0, day 2 and day 5, by itself.
|
|
2378
|
+
--
|
|
2379
|
+
-- This plugin's README has promised "multi-step sequences, and enrollment
|
|
2380
|
+
-- stats" since M1, and \`AutomationsList.tsx\` says plainly what shipped instead:
|
|
2381
|
+
-- "um fluxo de um passo \xE9 o texto com o gatilho escrito na frente". One row per
|
|
2382
|
+
-- (event, channel). Nothing waits, nothing remembers where a person got to, and
|
|
2383
|
+
-- nothing stops when they reply.
|
|
2384
|
+
--
|
|
2385
|
+
-- WHAT THIS DOES NOT BUILD, BECAUSE IT ALREADY EXISTS.
|
|
2386
|
+
--
|
|
2387
|
+
-- * The clock. \`fayz_event_runner\` is a pg_cron job that already calls
|
|
2388
|
+
-- \`event_runner_tick(200)\` every minute. This adds a fifth call to it
|
|
2389
|
+
-- (packages/db 046), guarded like the other four.
|
|
2390
|
+
-- * The delay. \`notifications_enqueue(..., p_scheduled_for, p_dedupe_key)\`
|
|
2391
|
+
-- already means "send this at time T, and not twice". A cadence step is a
|
|
2392
|
+
-- row in that queue with a computed \`scheduled_for\`.
|
|
2393
|
+
-- * Consent, channel preference and quiet-channel fallback \u2014 all inside
|
|
2394
|
+
-- \`notifications_resolve_channel\`, which plugin-notifications 002 just
|
|
2395
|
+
-- taught to read the opt-out correctly. A cadence that dodged the queue
|
|
2396
|
+
-- would dodge consent with it, which is the whole reason it does not.
|
|
2397
|
+
-- * The claim-and-retry shape. \`marketing_run_due\` / \`marketing_campaign_run\`
|
|
2398
|
+
-- already solve "advance a set of enrolled people on a tick" with
|
|
2399
|
+
-- \`FOR UPDATE SKIP LOCKED\`. \`automations_flows_run_due\` is that function
|
|
2400
|
+
-- with a step pointer.
|
|
2401
|
+
--
|
|
2402
|
+
-- THE THREE DECISIONS WORTH ARGUING WITH.
|
|
2403
|
+
--
|
|
2404
|
+
-- 1. THE DELAY IS AN OFFSET FROM THE PREVIOUS STEP, NOT FROM ENROLMENT.
|
|
2405
|
+
-- "Two days after the call" is how a salesperson describes a cadence, and it
|
|
2406
|
+
-- survives inserting a step in the middle. Absolute offsets from day 0 mean
|
|
2407
|
+
-- every later step silently shifts when you add one.
|
|
2408
|
+
--
|
|
2409
|
+
-- 2. THE ENROLMENT CARRIES THE STEP POINTER, AND THE DEDUPE KEY CARRIES THE
|
|
2410
|
+
-- POSITION. \`flow:<enrolment>:<position>\` means replaying a tick, or two
|
|
2411
|
+
-- workers taking the same row, cannot send a step twice \u2014
|
|
2412
|
+
-- \`notifications_enqueue\` refuses a repeated \`dedupe_key\` before it writes
|
|
2413
|
+
-- anything. The SKIP LOCKED claim is the first defence; this is the one that
|
|
2414
|
+
-- survives a crash between the enqueue and the pointer update.
|
|
2415
|
+
--
|
|
2416
|
+
-- 3. A FLOW IS BORN INACTIVE. \`is_active\` defaults false. A cadence that starts
|
|
2417
|
+
-- sending the moment somebody saves a draft is a cadence that sends a draft.
|
|
2418
|
+
--
|
|
2419
|
+
-- WHAT IS STILL MISSING AFTER THIS, AND IS NOT PRETENDED OTHERWISE: e-mail and
|
|
2420
|
+
-- SMS still resolve to \`notifications_send_to_log\`, which records and sends
|
|
2421
|
+
-- nothing. A cadence on those channels will enqueue, drain, and deliver to a
|
|
2422
|
+
-- log row. WhatsApp reaches a provider; in-app reaches the bell.
|
|
2423
|
+
|
|
2424
|
+
SET check_function_bodies = false;
|
|
2425
|
+
|
|
2426
|
+
-- \u2500\u2500 tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2427
|
+
|
|
2428
|
+
CREATE TABLE IF NOT EXISTS public.plg_automations_flows (
|
|
2429
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
2430
|
+
tenant_id uuid NOT NULL,
|
|
2431
|
+
unit_id uuid,
|
|
2432
|
+
name text NOT NULL,
|
|
2433
|
+
description text,
|
|
2434
|
+
-- The fact that puts somebody in. Names an event from the aggregated plugin
|
|
2435
|
+
-- catalog (\`crm.lead_created\`, \`crm.stage_changed\`, \u2026); it is text and not a
|
|
2436
|
+
-- foreign key because the catalog is assembled at runtime from whichever
|
|
2437
|
+
-- plugins an app mounts.
|
|
2438
|
+
entry_event text NOT NULL,
|
|
2439
|
+
-- The facts that take somebody out. Empty means the flow only ends by
|
|
2440
|
+
-- running out of steps.
|
|
2441
|
+
exit_events text[] DEFAULT '{}'::text[] NOT NULL,
|
|
2442
|
+
is_active boolean DEFAULT false NOT NULL,
|
|
2443
|
+
owner_id uuid,
|
|
2444
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
2445
|
+
updated_at timestamptz DEFAULT now() NOT NULL,
|
|
2446
|
+
CONSTRAINT plg_automations_flows_pkey PRIMARY KEY (id)
|
|
2447
|
+
);
|
|
2448
|
+
|
|
2449
|
+
CREATE TABLE IF NOT EXISTS public.plg_automations_flow_steps (
|
|
2450
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
2451
|
+
tenant_id uuid NOT NULL,
|
|
2452
|
+
flow_id uuid NOT NULL,
|
|
2453
|
+
-- Sparse on purpose (10, 20, 30\u2026): inserting between two steps should not
|
|
2454
|
+
-- renumber the ones after it, and the enrolment's pointer names a position.
|
|
2455
|
+
position integer NOT NULL,
|
|
2456
|
+
-- Minutes to wait AFTER THE PREVIOUS STEP. The first step's delay is measured
|
|
2457
|
+
-- from enrolment, so 0 means "right away".
|
|
2458
|
+
delay_minutes integer DEFAULT 0 NOT NULL,
|
|
2459
|
+
kind text DEFAULT 'message' NOT NULL,
|
|
2460
|
+
-- kind='message': which template, and optionally force a channel. NULL
|
|
2461
|
+
-- channel lets notifications_resolve_channel pick, which is what honours the
|
|
2462
|
+
-- recipient's preference.
|
|
2463
|
+
template_key text,
|
|
2464
|
+
channel text,
|
|
2465
|
+
-- kind='task': what the human is asked to do, and by when.
|
|
2466
|
+
task_title text,
|
|
2467
|
+
task_due_days integer,
|
|
2468
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
2469
|
+
updated_at timestamptz DEFAULT now() NOT NULL,
|
|
2470
|
+
CONSTRAINT plg_automations_flow_steps_pkey PRIMARY KEY (id),
|
|
2471
|
+
CONSTRAINT plg_automations_flow_steps_kind_check
|
|
2472
|
+
CHECK (kind = ANY (ARRAY['message'::text, 'task'::text])),
|
|
2473
|
+
CONSTRAINT plg_automations_flow_steps_delay_check CHECK (delay_minutes >= 0),
|
|
2474
|
+
-- A message step with no template has nothing to send; a task step with no
|
|
2475
|
+
-- title gives the human nothing to read. Either is a step that would fail at
|
|
2476
|
+
-- 3am rather than at save time.
|
|
2477
|
+
CONSTRAINT plg_automations_flow_steps_payload_check CHECK (
|
|
2478
|
+
(kind = 'message' AND template_key IS NOT NULL)
|
|
2479
|
+
OR (kind = 'task' AND task_title IS NOT NULL)
|
|
2480
|
+
)
|
|
2481
|
+
);
|
|
2482
|
+
|
|
2483
|
+
CREATE TABLE IF NOT EXISTS public.plg_automations_enrollments (
|
|
2484
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
2485
|
+
tenant_id uuid NOT NULL,
|
|
2486
|
+
flow_id uuid NOT NULL,
|
|
2487
|
+
person_id uuid NOT NULL,
|
|
2488
|
+
-- What put them here, for the timeline: ('lead', <id>) or ('deal', <id>).
|
|
2489
|
+
subject_type text,
|
|
2490
|
+
subject_id uuid,
|
|
2491
|
+
status text DEFAULT 'active' NOT NULL,
|
|
2492
|
+
-- The position of the step that runs NEXT. NULL once nothing is left.
|
|
2493
|
+
current_position integer,
|
|
2494
|
+
next_run_at timestamptz DEFAULT now() NOT NULL,
|
|
2495
|
+
entered_at timestamptz DEFAULT now() NOT NULL,
|
|
2496
|
+
exited_at timestamptz,
|
|
2497
|
+
exit_reason text,
|
|
2498
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
2499
|
+
updated_at timestamptz DEFAULT now() NOT NULL,
|
|
2500
|
+
CONSTRAINT plg_automations_enrollments_pkey PRIMARY KEY (id),
|
|
2501
|
+
CONSTRAINT plg_automations_enrollments_status_check
|
|
2502
|
+
CHECK (status = ANY (ARRAY['active'::text, 'completed'::text, 'exited'::text]))
|
|
2503
|
+
);
|
|
2504
|
+
|
|
2505
|
+
-- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2506
|
+
|
|
2507
|
+
ALTER TABLE public.plg_automations_flows
|
|
2508
|
+
DROP CONSTRAINT IF EXISTS plg_automations_flows_tenant_fkey;
|
|
2509
|
+
ALTER TABLE public.plg_automations_flows
|
|
2510
|
+
ADD CONSTRAINT plg_automations_flows_tenant_fkey
|
|
2511
|
+
FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
2512
|
+
|
|
2513
|
+
ALTER TABLE public.plg_automations_flow_steps
|
|
2514
|
+
DROP CONSTRAINT IF EXISTS plg_automations_flow_steps_flow_fkey;
|
|
2515
|
+
ALTER TABLE public.plg_automations_flow_steps
|
|
2516
|
+
ADD CONSTRAINT plg_automations_flow_steps_flow_fkey
|
|
2517
|
+
FOREIGN KEY (flow_id) REFERENCES public.plg_automations_flows(id) ON DELETE CASCADE;
|
|
2518
|
+
|
|
2519
|
+
ALTER TABLE public.plg_automations_enrollments
|
|
2520
|
+
DROP CONSTRAINT IF EXISTS plg_automations_enrollments_flow_fkey;
|
|
2521
|
+
ALTER TABLE public.plg_automations_enrollments
|
|
2522
|
+
ADD CONSTRAINT plg_automations_enrollments_flow_fkey
|
|
2523
|
+
FOREIGN KEY (flow_id) REFERENCES public.plg_automations_flows(id) ON DELETE CASCADE;
|
|
2524
|
+
|
|
2525
|
+
-- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2526
|
+
|
|
2527
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_automations_flow_steps_position_uq
|
|
2528
|
+
ON public.plg_automations_flow_steps (flow_id, position);
|
|
2529
|
+
CREATE INDEX IF NOT EXISTS plg_automations_flows_tenant_idx
|
|
2530
|
+
ON public.plg_automations_flows (tenant_id);
|
|
2531
|
+
CREATE INDEX IF NOT EXISTS plg_automations_flows_entry_idx
|
|
2532
|
+
ON public.plg_automations_flows (tenant_id, entry_event) WHERE is_active;
|
|
2533
|
+
|
|
2534
|
+
-- One live enrolment per person per flow. Re-entering a cadence somebody is
|
|
2535
|
+
-- already in would double every remaining message.
|
|
2536
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_automations_enrollments_live_uq
|
|
2537
|
+
ON public.plg_automations_enrollments (flow_id, person_id) WHERE status = 'active';
|
|
2538
|
+
-- The claim index: the runner only ever asks for due, active rows.
|
|
2539
|
+
CREATE INDEX IF NOT EXISTS plg_automations_enrollments_due_idx
|
|
2540
|
+
ON public.plg_automations_enrollments (next_run_at) WHERE status = 'active';
|
|
2541
|
+
CREATE INDEX IF NOT EXISTS plg_automations_enrollments_person_idx
|
|
2542
|
+
ON public.plg_automations_enrollments (tenant_id, person_id);
|
|
2543
|
+
|
|
2544
|
+
-- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2545
|
+
|
|
2546
|
+
-- ENABLE **and** FORCE. Without FORCE the table owner \u2014 which is the role that
|
|
2547
|
+
-- runs migrations and every SECURITY DEFINER function here \u2014 bypasses these
|
|
2548
|
+
-- policies entirely, and \`packages/db/test/pgtap/900_verification/001_posture.sql\`
|
|
2549
|
+
-- (N14b) fails the whole pool for it.
|
|
2550
|
+
ALTER TABLE public.plg_automations_flows ENABLE ROW LEVEL SECURITY;
|
|
2551
|
+
ALTER TABLE public.plg_automations_flows FORCE ROW LEVEL SECURITY;
|
|
2552
|
+
ALTER TABLE public.plg_automations_flow_steps ENABLE ROW LEVEL SECURITY;
|
|
2553
|
+
ALTER TABLE public.plg_automations_flow_steps FORCE ROW LEVEL SECURITY;
|
|
2554
|
+
ALTER TABLE public.plg_automations_enrollments ENABLE ROW LEVEL SECURITY;
|
|
2555
|
+
ALTER TABLE public.plg_automations_enrollments FORCE ROW LEVEL SECURITY;
|
|
2556
|
+
|
|
2557
|
+
DROP POLICY IF EXISTS plg_automations_flows_authz_select ON public.plg_automations_flows;
|
|
2558
|
+
CREATE POLICY plg_automations_flows_authz_select ON public.plg_automations_flows
|
|
2559
|
+
FOR SELECT TO authenticated USING (
|
|
2560
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2561
|
+
AND (SELECT app.has_permission('automations.read', NULL::uuid)));
|
|
2562
|
+
|
|
2563
|
+
DROP POLICY IF EXISTS plg_automations_flows_authz_insert ON public.plg_automations_flows;
|
|
2564
|
+
CREATE POLICY plg_automations_flows_authz_insert ON public.plg_automations_flows
|
|
2565
|
+
FOR INSERT TO authenticated WITH CHECK (
|
|
2566
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2567
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)));
|
|
2568
|
+
|
|
2569
|
+
DROP POLICY IF EXISTS plg_automations_flows_authz_update ON public.plg_automations_flows;
|
|
2570
|
+
CREATE POLICY plg_automations_flows_authz_update ON public.plg_automations_flows
|
|
2571
|
+
FOR UPDATE TO authenticated USING (
|
|
2572
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2573
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)))
|
|
2574
|
+
WITH CHECK (
|
|
2575
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2576
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)));
|
|
2577
|
+
|
|
2578
|
+
DROP POLICY IF EXISTS plg_automations_flows_authz_delete ON public.plg_automations_flows;
|
|
2579
|
+
CREATE POLICY plg_automations_flows_authz_delete ON public.plg_automations_flows
|
|
2580
|
+
FOR DELETE TO authenticated USING (
|
|
2581
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2582
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)));
|
|
2583
|
+
|
|
2584
|
+
DROP POLICY IF EXISTS plg_automations_flow_steps_authz_select ON public.plg_automations_flow_steps;
|
|
2585
|
+
CREATE POLICY plg_automations_flow_steps_authz_select ON public.plg_automations_flow_steps
|
|
2586
|
+
FOR SELECT TO authenticated USING (
|
|
2587
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2588
|
+
AND (SELECT app.has_permission('automations.read', NULL::uuid)));
|
|
2589
|
+
|
|
2590
|
+
DROP POLICY IF EXISTS plg_automations_flow_steps_authz_insert ON public.plg_automations_flow_steps;
|
|
2591
|
+
-- The parent flow is checked, not just the stamped tenant. A tenant-only
|
|
2592
|
+
-- WITH CHECK lets a caller stamp their OWN tenant_id on a step and hang it off
|
|
2593
|
+
-- somebody else's flow \u2014 and the engine looks steps up by flow_id alone, so the
|
|
2594
|
+
-- injected step runs in that tenant's cadence.
|
|
2595
|
+
CREATE POLICY plg_automations_flow_steps_authz_insert ON public.plg_automations_flow_steps
|
|
2596
|
+
FOR INSERT TO authenticated WITH CHECK (
|
|
2597
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2598
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid))
|
|
2599
|
+
AND EXISTS (SELECT 1 FROM public.plg_automations_flows f
|
|
2600
|
+
WHERE f.id = flow_id AND f.tenant_id = (SELECT app.current_tenant_id())));
|
|
2601
|
+
|
|
2602
|
+
DROP POLICY IF EXISTS plg_automations_flow_steps_authz_update ON public.plg_automations_flow_steps;
|
|
2603
|
+
CREATE POLICY plg_automations_flow_steps_authz_update ON public.plg_automations_flow_steps
|
|
2604
|
+
FOR UPDATE TO authenticated USING (
|
|
2605
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2606
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)))
|
|
2607
|
+
WITH CHECK (
|
|
2608
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2609
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)));
|
|
2610
|
+
|
|
2611
|
+
DROP POLICY IF EXISTS plg_automations_flow_steps_authz_delete ON public.plg_automations_flow_steps;
|
|
2612
|
+
CREATE POLICY plg_automations_flow_steps_authz_delete ON public.plg_automations_flow_steps
|
|
2613
|
+
FOR DELETE TO authenticated USING (
|
|
2614
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2615
|
+
AND (SELECT app.has_permission('automations.edit', NULL::uuid)));
|
|
2616
|
+
|
|
2617
|
+
-- Enrolments are READ-ONLY to the operator. They are written by the engine, and
|
|
2618
|
+
-- a hand-edited step pointer is a cadence nobody can explain afterwards.
|
|
2619
|
+
-- Leaving a flow is \`automations_flow_exit\`, not an UPDATE.
|
|
2620
|
+
DROP POLICY IF EXISTS plg_automations_enrollments_authz_select ON public.plg_automations_enrollments;
|
|
2621
|
+
CREATE POLICY plg_automations_enrollments_authz_select ON public.plg_automations_enrollments
|
|
2622
|
+
FOR SELECT TO authenticated USING (
|
|
2623
|
+
tenant_id = (SELECT app.current_tenant_id())
|
|
2624
|
+
AND (SELECT app.has_permission('automations.read', NULL::uuid)));
|
|
2625
|
+
|
|
2626
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_automations_flows TO authenticated;
|
|
2627
|
+
GRANT ALL ON public.plg_automations_flows TO service_role;
|
|
2628
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_automations_flow_steps TO authenticated;
|
|
2629
|
+
GRANT ALL ON public.plg_automations_flow_steps TO service_role;
|
|
2630
|
+
GRANT SELECT ON public.plg_automations_enrollments TO authenticated;
|
|
2631
|
+
GRANT ALL ON public.plg_automations_enrollments TO service_role;
|
|
2632
|
+
|
|
2633
|
+
INSERT INTO app.scaffold_registry
|
|
2634
|
+
(table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)
|
|
2635
|
+
VALUES
|
|
2636
|
+
('public', 'plg_automations_flows', 'automations.flow', 'automations', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false),
|
|
2637
|
+
('public', 'plg_automations_flow_steps', 'automations.flow_step', 'automations', false, 'enforce', ARRAY[]::text[], false, 'tenant', false),
|
|
2638
|
+
('public', 'plg_automations_enrollments', 'automations.enrollment', 'automations', false, 'enforce', ARRAY[]::text[], false, 'tenant', false)
|
|
2639
|
+
ON CONFLICT DO NOTHING;
|
|
2640
|
+
|
|
2641
|
+
-- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2642
|
+
|
|
2643
|
+
-- Put somebody into a flow. Returns the enrolment, or NULL when the flow is
|
|
2644
|
+
-- inactive, has no steps, or the person is already in it.
|
|
2645
|
+
CREATE OR REPLACE FUNCTION public.automations_flow_enroll(
|
|
2646
|
+
p_tenant_id uuid, p_flow_id uuid, p_person_id uuid,
|
|
2647
|
+
p_subject_type text DEFAULT NULL, p_subject_id uuid DEFAULT NULL
|
|
2648
|
+
) RETURNS uuid
|
|
2649
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2650
|
+
SET search_path TO ''
|
|
2651
|
+
AS $$
|
|
2652
|
+
DECLARE v_first record; v_id uuid;
|
|
2653
|
+
BEGIN
|
|
2654
|
+
SELECT s.position, s.delay_minutes INTO v_first
|
|
2655
|
+
FROM public.plg_automations_flow_steps s
|
|
2656
|
+
JOIN public.plg_automations_flows f ON f.id = s.flow_id
|
|
2657
|
+
WHERE s.flow_id = p_flow_id AND f.tenant_id = p_tenant_id AND f.is_active
|
|
2658
|
+
ORDER BY s.position
|
|
2659
|
+
LIMIT 1;
|
|
2660
|
+
|
|
2661
|
+
-- An inactive flow, or one with no steps, enrols nobody. Silently: this is
|
|
2662
|
+
-- called from an event handler, and raising would fail the whole delivery.
|
|
2663
|
+
IF v_first.position IS NULL THEN RETURN NULL; END IF;
|
|
2664
|
+
|
|
2665
|
+
INSERT INTO public.plg_automations_enrollments
|
|
2666
|
+
(tenant_id, flow_id, person_id, subject_type, subject_id, current_position, next_run_at)
|
|
2667
|
+
VALUES
|
|
2668
|
+
(p_tenant_id, p_flow_id, p_person_id, p_subject_type, p_subject_id,
|
|
2669
|
+
v_first.position, now() + make_interval(mins => v_first.delay_minutes))
|
|
2670
|
+
ON CONFLICT DO NOTHING
|
|
2671
|
+
RETURNING id INTO v_id;
|
|
2672
|
+
|
|
2673
|
+
RETURN v_id;
|
|
2674
|
+
END $$;
|
|
2675
|
+
|
|
2676
|
+
-- Take somebody out of every flow they are in. The reason is kept, because
|
|
2677
|
+
-- "left because they replied" and "left because the deal was lost" are the two
|
|
2678
|
+
-- halves of whether a cadence works.
|
|
2679
|
+
CREATE OR REPLACE FUNCTION public.automations_flow_exit(
|
|
2680
|
+
p_tenant_id uuid, p_person_id uuid, p_reason text, p_flow_ids uuid[] DEFAULT NULL
|
|
2681
|
+
) RETURNS integer
|
|
2682
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2683
|
+
SET search_path TO ''
|
|
2684
|
+
AS $$
|
|
2685
|
+
DECLARE v_n integer; v_gone uuid[];
|
|
2686
|
+
BEGIN
|
|
2687
|
+
-- SCOPED TO THE FLOWS THAT ASKED. \`p_flow_ids\` NULL still means "every flow",
|
|
2688
|
+
-- which is what a manual "take this person out" wants; the event handler
|
|
2689
|
+
-- passes the flows whose own \`exit_events\` name the fact.
|
|
2690
|
+
--
|
|
2691
|
+
-- Without the scope, one flow declaring \`crm.deal_won\` pulled the person out
|
|
2692
|
+
-- of EVERY flow they were in and cancelled every queued step \u2014 including a
|
|
2693
|
+
-- flow that declared no exits at all, whose comment says it "only ends by
|
|
2694
|
+
-- running out of steps". Flow-level exit_events were meaningless the moment a
|
|
2695
|
+
-- second flow existed.
|
|
2696
|
+
WITH gone AS (
|
|
2697
|
+
UPDATE public.plg_automations_enrollments
|
|
2698
|
+
SET status = 'exited', exited_at = now(), exit_reason = p_reason,
|
|
2699
|
+
current_position = NULL, updated_at = now()
|
|
2700
|
+
WHERE tenant_id = p_tenant_id AND person_id = p_person_id AND status = 'active'
|
|
2701
|
+
AND (p_flow_ids IS NULL OR flow_id = ANY (p_flow_ids))
|
|
2702
|
+
RETURNING id
|
|
2703
|
+
)
|
|
2704
|
+
SELECT count(*)::integer, array_agg(id) INTO v_n, v_gone FROM gone;
|
|
2705
|
+
|
|
2706
|
+
-- A step already in the queue but not yet sent must not go out after the
|
|
2707
|
+
-- person left. \`cancelled\` was in the status CHECK from the beginning and
|
|
2708
|
+
-- nothing ever wrote it; this is what it was for.
|
|
2709
|
+
--
|
|
2710
|
+
-- Keyed by the ENROLMENTS that just left, not by \`flow:%\` \u2014 the wide pattern
|
|
2711
|
+
-- cancelled the pending steps of flows the person is still in.
|
|
2712
|
+
IF v_n > 0 AND to_regclass('public.plg_notifications_queue') IS NOT NULL THEN
|
|
2713
|
+
UPDATE public.plg_notifications_queue q
|
|
2714
|
+
SET status = 'cancelled', last_error = 'left the flow: ' || p_reason
|
|
2715
|
+
WHERE q.tenant_id = p_tenant_id
|
|
2716
|
+
AND q.person_id = p_person_id
|
|
2717
|
+
AND q.status = 'pending'
|
|
2718
|
+
AND split_part(q.dedupe_key, ':', 1) = 'flow'
|
|
2719
|
+
AND split_part(q.dedupe_key, ':', 2) = ANY (SELECT unnest(v_gone)::text);
|
|
2720
|
+
END IF;
|
|
2721
|
+
|
|
2722
|
+
RETURN v_n;
|
|
2723
|
+
END $$;
|
|
2724
|
+
|
|
2725
|
+
-- The tick. Claims due enrolments, runs ONE step each, moves the pointer.
|
|
2726
|
+
CREATE OR REPLACE FUNCTION public.automations_flows_run_due(p_limit integer DEFAULT 50)
|
|
2727
|
+
RETURNS jsonb
|
|
2728
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2729
|
+
SET search_path TO ''
|
|
2730
|
+
AS $$
|
|
2731
|
+
DECLARE
|
|
2732
|
+
e record; v_step record; v_next record; v_error text;
|
|
2733
|
+
v_ran integer := 0; v_done integer := 0; v_failed integer := 0;
|
|
2734
|
+
BEGIN
|
|
2735
|
+
-- JOINED to the flow, and \`f.is_active\` checked here rather than only in
|
|
2736
|
+
-- \`automations_flow_enroll\`. Switching a flow off used to stop new entrants
|
|
2737
|
+
-- and nothing else: everybody already mid-cadence kept receiving messages
|
|
2738
|
+
-- from a flow the operator had just turned off, which is the opposite of what
|
|
2739
|
+
-- the switch promises.
|
|
2740
|
+
FOR e IN
|
|
2741
|
+
SELECT en.* FROM public.plg_automations_enrollments en
|
|
2742
|
+
JOIN public.plg_automations_flows f ON f.id = en.flow_id AND f.is_active
|
|
2743
|
+
WHERE en.status = 'active' AND en.next_run_at <= now()
|
|
2744
|
+
ORDER BY en.next_run_at
|
|
2745
|
+
LIMIT greatest(p_limit, 0)
|
|
2746
|
+
FOR UPDATE OF en SKIP LOCKED
|
|
2747
|
+
LOOP
|
|
2748
|
+
SELECT * INTO v_step FROM public.plg_automations_flow_steps s
|
|
2749
|
+
WHERE s.flow_id = e.flow_id AND s.position = e.current_position;
|
|
2750
|
+
|
|
2751
|
+
-- The step was deleted while somebody stood on it. Move on rather than
|
|
2752
|
+
-- spinning on a pointer that will never resolve.
|
|
2753
|
+
IF v_step.id IS NULL THEN
|
|
2754
|
+
SELECT * INTO v_next FROM public.plg_automations_flow_steps s
|
|
2755
|
+
WHERE s.flow_id = e.flow_id AND s.position > coalesce(e.current_position, -1)
|
|
2756
|
+
ORDER BY s.position LIMIT 1;
|
|
2757
|
+
ELSE
|
|
2758
|
+
BEGIN
|
|
2759
|
+
IF v_step.kind = 'message' THEN
|
|
2760
|
+
PERFORM public.notifications_enqueue(
|
|
2761
|
+
e.tenant_id, v_step.template_key, e.person_id,
|
|
2762
|
+
jsonb_build_object('flow_id', e.flow_id, 'enrollment_id', e.id, 'step', v_step.position),
|
|
2763
|
+
'flow:' || e.id::text || ':' || v_step.position::text,
|
|
2764
|
+
now(),
|
|
2765
|
+
v_step.channel
|
|
2766
|
+
);
|
|
2767
|
+
ELSIF v_step.kind = 'task' AND to_regclass('public.tasks') IS NOT NULL THEN
|
|
2768
|
+
-- \`tasks_source_uniq (tenant_id, source, source_key)\` makes this
|
|
2769
|
+
-- idempotent for free \u2014 the same step cannot raise two tasks.
|
|
2770
|
+
INSERT INTO public.tasks
|
|
2771
|
+
(tenant_id, title, description, status, due_date, source, source_key)
|
|
2772
|
+
VALUES
|
|
2773
|
+
(e.tenant_id, v_step.task_title,
|
|
2774
|
+
'Automation flow step ' || v_step.position::text,
|
|
2775
|
+
'todo',
|
|
2776
|
+
CASE WHEN v_step.task_due_days IS NOT NULL
|
|
2777
|
+
THEN (now() + make_interval(days => v_step.task_due_days))::date END,
|
|
2778
|
+
'automation:flow', e.id::text || ':' || v_step.position::text)
|
|
2779
|
+
ON CONFLICT DO NOTHING;
|
|
2780
|
+
END IF;
|
|
2781
|
+
v_ran := v_ran + 1;
|
|
2782
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2783
|
+
-- One bad step must not stop the tick for everybody else, so the
|
|
2784
|
+
-- enrolment still leaves this step: a template that no longer exists
|
|
2785
|
+
-- would otherwise wedge the person forever.
|
|
2786
|
+
--
|
|
2787
|
+
-- But it does NOT read as a finished cadence. The first cut let the
|
|
2788
|
+
-- loop fall through to \`completed\`, and because a failed step never
|
|
2789
|
+
-- produces a queue row, \`v_automations_flow_steps\` \u2014 which counts queue
|
|
2790
|
+
-- statuses \u2014 could not show it either. The screen built to answer
|
|
2791
|
+
-- "where do people stop" reported the person as having finished.
|
|
2792
|
+
v_failed := v_failed + 1;
|
|
2793
|
+
v_error := SQLERRM;
|
|
2794
|
+
RAISE NOTICE 'automations_flows_run_due: step % of enrolment % failed (%)',
|
|
2795
|
+
v_step.position, e.id, SQLERRM;
|
|
2796
|
+
END;
|
|
2797
|
+
|
|
2798
|
+
SELECT * INTO v_next FROM public.plg_automations_flow_steps s
|
|
2799
|
+
WHERE s.flow_id = e.flow_id AND s.position > v_step.position
|
|
2800
|
+
ORDER BY s.position LIMIT 1;
|
|
2801
|
+
END IF;
|
|
2802
|
+
|
|
2803
|
+
IF v_next.id IS NULL THEN
|
|
2804
|
+
UPDATE public.plg_automations_enrollments
|
|
2805
|
+
SET status = 'completed', current_position = NULL,
|
|
2806
|
+
exited_at = now(), updated_at = now(),
|
|
2807
|
+
-- A cadence that ended on a step that could not run did not
|
|
2808
|
+
-- finish; saying so is the difference between "nobody answers the
|
|
2809
|
+
-- third message" and "the third message never left".
|
|
2810
|
+
exit_reason = CASE WHEN v_error IS NOT NULL
|
|
2811
|
+
THEN 'last step failed: ' || v_error END
|
|
2812
|
+
WHERE id = e.id;
|
|
2813
|
+
v_done := v_done + 1;
|
|
2814
|
+
ELSE
|
|
2815
|
+
UPDATE public.plg_automations_enrollments
|
|
2816
|
+
SET current_position = v_next.position,
|
|
2817
|
+
next_run_at = now() + make_interval(mins => v_next.delay_minutes),
|
|
2818
|
+
updated_at = now()
|
|
2819
|
+
WHERE id = e.id;
|
|
2820
|
+
END IF;
|
|
2821
|
+
|
|
2822
|
+
v_next := NULL; v_step := NULL; v_error := NULL;
|
|
2823
|
+
END LOOP;
|
|
2824
|
+
|
|
2825
|
+
RETURN jsonb_build_object('steps_ran', v_ran, 'completed', v_done, 'failed', v_failed);
|
|
2826
|
+
END $$;
|
|
2827
|
+
|
|
2828
|
+
REVOKE ALL ON FUNCTION public.automations_flow_enroll(uuid, uuid, uuid, text, uuid) FROM PUBLIC;
|
|
2829
|
+
GRANT EXECUTE ON FUNCTION public.automations_flow_enroll(uuid, uuid, uuid, text, uuid) TO service_role;
|
|
2830
|
+
REVOKE ALL ON FUNCTION public.automations_flow_exit(uuid, uuid, text, uuid[]) FROM PUBLIC;
|
|
2831
|
+
GRANT EXECUTE ON FUNCTION public.automations_flow_exit(uuid, uuid, text, uuid[]) TO authenticated;
|
|
2832
|
+
GRANT EXECUTE ON FUNCTION public.automations_flow_exit(uuid, uuid, text, uuid[]) TO service_role;
|
|
2833
|
+
REVOKE ALL ON FUNCTION public.automations_flows_run_due(integer) FROM PUBLIC;
|
|
2834
|
+
GRANT EXECUTE ON FUNCTION public.automations_flows_run_due(integer) TO service_role;
|
|
2835
|
+
|
|
2836
|
+
-- \u2500\u2500 the two handlers the event log calls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2837
|
+
|
|
2838
|
+
-- Who the event is about.
|
|
2839
|
+
--
|
|
2840
|
+
-- \`consume_event_log\` calls a handler with \`to_jsonb(v_event)\` \u2014 the whole
|
|
2841
|
+
-- \`event_log\` ROW. So \`tenant_id\`, \`event_key\`, \`subject_type\` and \`subject_id\`
|
|
2842
|
+
-- are at the top level and everything the emitter chose to send is nested under
|
|
2843
|
+
-- \`payload\`. The first cut read \`person_id\` at the top level, where it never is:
|
|
2844
|
+
-- every delivery answered "payload carries no tenant, person or event", was
|
|
2845
|
+
-- marked DELIVERED, and the cadence enrolled nobody, ever, with nothing
|
|
2846
|
+
-- reporting it.
|
|
2847
|
+
--
|
|
2848
|
+
-- \`subject_id\` first, because that is the convention the shipped handlers use
|
|
2849
|
+
-- (\`plg_notifications_on_fulfillment_completed\` reads exactly that), and it is
|
|
2850
|
+
-- \`text\` on the row. The payload fallbacks are for emitters that name the person
|
|
2851
|
+
-- there instead.
|
|
2852
|
+
CREATE OR REPLACE FUNCTION public.automations_event_person(p_event jsonb)
|
|
2853
|
+
RETURNS uuid
|
|
2854
|
+
LANGUAGE sql IMMUTABLE
|
|
2855
|
+
SET search_path TO ''
|
|
2856
|
+
AS $$
|
|
2857
|
+
SELECT nullif(coalesce(
|
|
2858
|
+
CASE WHEN p_event ->> 'subject_type' IN ('lead', 'person', 'contact')
|
|
2859
|
+
THEN p_event ->> 'subject_id' END,
|
|
2860
|
+
p_event -> 'payload' ->> 'person_id',
|
|
2861
|
+
p_event -> 'payload' ->> 'lead_id',
|
|
2862
|
+
p_event -> 'payload' ->> 'contact_id',
|
|
2863
|
+
p_event ->> 'person_id',
|
|
2864
|
+
p_event ->> 'lead_id'
|
|
2865
|
+
), '')::uuid
|
|
2866
|
+
$$;
|
|
2867
|
+
|
|
2868
|
+
REVOKE ALL ON FUNCTION public.automations_event_person(jsonb) FROM PUBLIC;
|
|
2869
|
+
GRANT EXECUTE ON FUNCTION public.automations_event_person(jsonb) TO service_role;
|
|
2870
|
+
|
|
2871
|
+
-- Enrol on the flow's entry event. The payload names the person; a fact that
|
|
2872
|
+
-- does not carry one cannot start a cadence.
|
|
2873
|
+
CREATE OR REPLACE FUNCTION public.plg_automations_on_entry_event(p_event jsonb)
|
|
2874
|
+
RETURNS jsonb
|
|
2875
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2876
|
+
SET search_path TO ''
|
|
2877
|
+
AS $$
|
|
2878
|
+
DECLARE
|
|
2879
|
+
v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;
|
|
2880
|
+
v_event text := p_event ->> 'event_key';
|
|
2881
|
+
v_person uuid := public.automations_event_person(p_event);
|
|
2882
|
+
f record; v_n integer := 0;
|
|
2883
|
+
BEGIN
|
|
2884
|
+
IF v_tenant IS NULL OR v_person IS NULL OR v_event IS NULL THEN
|
|
2885
|
+
RETURN jsonb_build_object('enrolled', 0, 'reason', 'event carries no tenant, person or key');
|
|
2886
|
+
END IF;
|
|
2887
|
+
|
|
2888
|
+
FOR f IN SELECT id FROM public.plg_automations_flows
|
|
2889
|
+
WHERE tenant_id = v_tenant AND entry_event = v_event AND is_active
|
|
2890
|
+
LOOP
|
|
2891
|
+
IF public.automations_flow_enroll(
|
|
2892
|
+
v_tenant, f.id, v_person,
|
|
2893
|
+
p_event ->> 'subject_type', nullif(p_event ->> 'subject_id', '')::uuid) IS NOT NULL THEN
|
|
2894
|
+
v_n := v_n + 1;
|
|
2895
|
+
END IF;
|
|
2896
|
+
END LOOP;
|
|
2897
|
+
|
|
2898
|
+
RETURN jsonb_build_object('enrolled', v_n);
|
|
2899
|
+
END $$;
|
|
2900
|
+
|
|
2901
|
+
-- Leave the flows whose own \`exit_events\` name this fact.
|
|
2902
|
+
CREATE OR REPLACE FUNCTION public.plg_automations_on_exit_event(p_event jsonb)
|
|
2903
|
+
RETURNS jsonb
|
|
2904
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2905
|
+
SET search_path TO ''
|
|
2906
|
+
AS $$
|
|
2907
|
+
DECLARE
|
|
2908
|
+
v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;
|
|
2909
|
+
v_event text := p_event ->> 'event_key';
|
|
2910
|
+
v_person uuid := public.automations_event_person(p_event);
|
|
2911
|
+
v_flows uuid[]; v_n integer := 0;
|
|
2912
|
+
BEGIN
|
|
2913
|
+
IF v_tenant IS NULL OR v_person IS NULL OR v_event IS NULL THEN
|
|
2914
|
+
RETURN jsonb_build_object('exited', 0, 'reason', 'event carries no tenant, person or key');
|
|
2915
|
+
END IF;
|
|
2916
|
+
|
|
2917
|
+
SELECT array_agg(id) INTO v_flows
|
|
2918
|
+
FROM public.plg_automations_flows
|
|
2919
|
+
WHERE tenant_id = v_tenant AND is_active AND v_event = ANY (exit_events);
|
|
2920
|
+
|
|
2921
|
+
IF v_flows IS NOT NULL THEN
|
|
2922
|
+
v_n := public.automations_flow_exit(v_tenant, v_person, v_event, v_flows);
|
|
2923
|
+
END IF;
|
|
2924
|
+
|
|
2925
|
+
RETURN jsonb_build_object('exited', v_n);
|
|
2926
|
+
END $$;
|
|
2927
|
+
|
|
2928
|
+
REVOKE ALL ON FUNCTION public.plg_automations_on_entry_event(jsonb) FROM PUBLIC;
|
|
2929
|
+
GRANT EXECUTE ON FUNCTION public.plg_automations_on_entry_event(jsonb) TO service_role;
|
|
2930
|
+
REVOKE ALL ON FUNCTION public.plg_automations_on_exit_event(jsonb) FROM PUBLIC;
|
|
2931
|
+
GRANT EXECUTE ON FUNCTION public.plg_automations_on_exit_event(jsonb) TO service_role;
|
|
2932
|
+
|
|
2933
|
+
-- \u2500\u2500 subscription \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2934
|
+
|
|
2935
|
+
-- The manifest DECLARES the reaction; this is what makes the event log call it.
|
|
2936
|
+
-- Both handlers on every CRM fact, because which fact opens a cadence and which
|
|
2937
|
+
-- closes it is the operator's choice: the handler looks the flow up and returns
|
|
2938
|
+
-- immediately when no flow in that tenant names the event.
|
|
2939
|
+
-- Written out one call per line rather than looped over an array. The loop was
|
|
2940
|
+
-- shorter and \`check-event-contract\` could not read it: its registration parser
|
|
2941
|
+
-- matches literal arguments, so a FOREACH registering fourteen subscriptions
|
|
2942
|
+
-- looked like zero. Same lesson as the manifest side \u2014 the gate reads source,
|
|
2943
|
+
-- so the source has to say it.
|
|
2944
|
+
SELECT public.register_event_subscription('automations', 'crm.lead_created', 'plg_automations_on_entry_event',
|
|
2945
|
+
'Enrol into flows whose entry event is crm.lead_created', 5);
|
|
2946
|
+
SELECT public.register_event_subscription('automations', 'crm.lead_created', 'plg_automations_on_exit_event',
|
|
2947
|
+
'Leave flows that exit on crm.lead_created', 5);
|
|
2948
|
+
SELECT public.register_event_subscription('automations', 'crm.stage_changed', 'plg_automations_on_entry_event',
|
|
2949
|
+
'Enrol into flows whose entry event is crm.stage_changed', 5);
|
|
2950
|
+
SELECT public.register_event_subscription('automations', 'crm.stage_changed', 'plg_automations_on_exit_event',
|
|
2951
|
+
'Leave flows that exit on crm.stage_changed', 5);
|
|
2952
|
+
SELECT public.register_event_subscription('automations', 'crm.quote_sent', 'plg_automations_on_entry_event',
|
|
2953
|
+
'Enrol into flows whose entry event is crm.quote_sent', 5);
|
|
2954
|
+
SELECT public.register_event_subscription('automations', 'crm.quote_sent', 'plg_automations_on_exit_event',
|
|
2955
|
+
'Leave flows that exit on crm.quote_sent', 5);
|
|
2956
|
+
SELECT public.register_event_subscription('automations', 'crm.quote_approved', 'plg_automations_on_entry_event',
|
|
2957
|
+
'Enrol into flows whose entry event is crm.quote_approved', 5);
|
|
2958
|
+
SELECT public.register_event_subscription('automations', 'crm.quote_approved', 'plg_automations_on_exit_event',
|
|
2959
|
+
'Leave flows that exit on crm.quote_approved', 5);
|
|
2960
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_won', 'plg_automations_on_entry_event',
|
|
2961
|
+
'Enrol into flows whose entry event is crm.deal_won', 5);
|
|
2962
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_won', 'plg_automations_on_exit_event',
|
|
2963
|
+
'Leave flows that exit on crm.deal_won', 5);
|
|
2964
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_lost', 'plg_automations_on_entry_event',
|
|
2965
|
+
'Enrol into flows whose entry event is crm.deal_lost', 5);
|
|
2966
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_lost', 'plg_automations_on_exit_event',
|
|
2967
|
+
'Leave flows that exit on crm.deal_lost', 5);
|
|
2968
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_idle', 'plg_automations_on_entry_event',
|
|
2969
|
+
'Enrol into flows whose entry event is crm.deal_idle', 5);
|
|
2970
|
+
SELECT public.register_event_subscription('automations', 'crm.deal_idle', 'plg_automations_on_exit_event',
|
|
2971
|
+
'Leave flows that exit on crm.deal_idle', 5);
|
|
2972
|
+
|
|
2973
|
+
-- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2974
|
+
|
|
2975
|
+
-- Per-step numbers, which is the question a cadence is judged by: where do
|
|
2976
|
+
-- people stop. Aggregating per FLOW would hide that the third message is the
|
|
2977
|
+
-- one nobody answers.
|
|
2978
|
+
CREATE OR REPLACE VIEW public.v_automations_flow_steps WITH (security_invoker='true') AS
|
|
2979
|
+
SELECT
|
|
2980
|
+
s.tenant_id, s.flow_id, s.id AS step_id, s.position, s.kind,
|
|
2981
|
+
s.template_key, s.channel, s.delay_minutes, s.task_title,
|
|
2982
|
+
count(q.id) FILTER (WHERE q.id IS NOT NULL) AS queued,
|
|
2983
|
+
count(q.id) FILTER (WHERE q.status = 'sent') AS sent,
|
|
2984
|
+
count(q.id) FILTER (WHERE q.status = 'blocked') AS blocked,
|
|
2985
|
+
count(q.id) FILTER (WHERE q.status = 'failed') AS failed,
|
|
2986
|
+
count(q.id) FILTER (WHERE q.status = 'cancelled') AS cancelled
|
|
2987
|
+
FROM public.plg_automations_flow_steps s
|
|
2988
|
+
LEFT JOIN public.plg_automations_enrollments e ON e.flow_id = s.flow_id
|
|
2989
|
+
LEFT JOIN public.plg_notifications_queue q
|
|
2990
|
+
ON q.dedupe_key = 'flow:' || e.id::text || ':' || s.position::text
|
|
2991
|
+
GROUP BY s.tenant_id, s.flow_id, s.id, s.position, s.kind,
|
|
2992
|
+
s.template_key, s.channel, s.delay_minutes, s.task_title;
|
|
2993
|
+
|
|
2994
|
+
GRANT SELECT ON public.v_automations_flow_steps TO authenticated;
|
|
2995
|
+
GRANT SELECT ON public.v_automations_flow_steps TO service_role;
|
|
2996
|
+
`;
|
|
2997
|
+
var MIGRATIONS = [
|
|
2998
|
+
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
2999
|
+
{ id: "001_the_flow_has_steps", sql: MIGRATION_001_THE_FLOW_HAS_STEPS }
|
|
3000
|
+
];
|
|
1530
3001
|
|
|
1531
3002
|
// src/index.ts
|
|
1532
3003
|
function createAutomationsPlugin(options) {
|
|
1533
3004
|
const connection = resolveFayzAgentConnection();
|
|
1534
|
-
const
|
|
3005
|
+
const store2 = options?.store ?? (connection ? createFayzTemplateStore(connection) : createSupabaseTemplateStore());
|
|
1535
3006
|
startEventLog();
|
|
1536
3007
|
const inSettings = options?.placement === "settings";
|
|
1537
|
-
const ListPage = () => React4__default.createElement(AutomationsList, { store });
|
|
3008
|
+
const ListPage = () => React4__default.createElement(AutomationsList, { store: store2 });
|
|
1538
3009
|
ListPage.displayName = "AutomationsList";
|
|
1539
|
-
const SettingsListPage = () => React4__default.createElement(AutomationsList, { store, variant: "panel", basePath: "/settings/automations" });
|
|
3010
|
+
const SettingsListPage = () => React4__default.createElement(AutomationsList, { store: store2, variant: "panel", basePath: "/settings/automations" });
|
|
1540
3011
|
SettingsListPage.displayName = "AutomationsSettings";
|
|
1541
3012
|
const RailDetail = ({ params, onClose }) => React4__default.createElement(AutomationDetailPage, {
|
|
1542
3013
|
eventKey: params.eventKey,
|
|
1543
3014
|
channel: params.channel,
|
|
1544
|
-
store,
|
|
3015
|
+
store: store2,
|
|
1545
3016
|
backTo: inSettings ? "/settings/automations" : "/automations",
|
|
1546
3017
|
embedded: true,
|
|
1547
3018
|
onDone: onClose
|
|
@@ -1569,7 +3040,19 @@ function createAutomationsPlugin(options) {
|
|
|
1569
3040
|
icon: "Zap",
|
|
1570
3041
|
permission: READ,
|
|
1571
3042
|
indexChild: false
|
|
1572
|
-
}
|
|
3043
|
+
},
|
|
3044
|
+
// The flows screen had a route and nothing pointing at it — no nav entry,
|
|
3045
|
+
// no link, no tab. `git grep automations/flows` found the registration
|
|
3046
|
+
// and the changeset, and nothing else.
|
|
3047
|
+
...inSettings ? [] : [{
|
|
3048
|
+
section: options?.navSection ?? "main",
|
|
3049
|
+
position: (options?.navPosition ?? 6) + 0.1,
|
|
3050
|
+
label: "Cad\xEAncias",
|
|
3051
|
+
route: "/automations/flows",
|
|
3052
|
+
icon: "Repeat",
|
|
3053
|
+
permission: READ,
|
|
3054
|
+
indexChild: false
|
|
3055
|
+
}]
|
|
1573
3056
|
],
|
|
1574
3057
|
// The SAME list, served inside settings — not a second screen.
|
|
1575
3058
|
settings: inSettings ? [
|
|
@@ -1582,8 +3065,57 @@ function createAutomationsPlugin(options) {
|
|
|
1582
3065
|
// mesmo componente: a shell colapsa '/x' e '/x/*' na mesma chave, então a
|
|
1583
3066
|
// página não remonta ao abrir o painel (ver AdminShell.routeKey). Um
|
|
1584
3067
|
// segundo componente aqui traria de volta a troca de tela.
|
|
3068
|
+
// Flows are their OWN route, not a tab of the list. A one-step automation
|
|
3069
|
+
// and a cadence answer different questions — "does the order confirmation
|
|
3070
|
+
// go out" versus "where do people stop" — and the wildcard above already
|
|
3071
|
+
// owns the right rail, so a tab here would fight it for the same URL.
|
|
3072
|
+
{ path: "/automations/flows", component: FlowsPage, permission: READ },
|
|
1585
3073
|
{ path: "/automations/*", component: ListPage, permission: READ }
|
|
1586
3074
|
],
|
|
3075
|
+
// A flow names its own entry and exit events, but a SUBSCRIPTION is a row:
|
|
3076
|
+
// static, written at install time. So both handlers listen to the whole CRM
|
|
3077
|
+
// fact catalogue and each one decides whether any flow cares — the handler
|
|
3078
|
+
// is a lookup against `plg_automations_flows`, and a tenant with no flow on
|
|
3079
|
+
// that event does one indexed read and returns.
|
|
3080
|
+
//
|
|
3081
|
+
// Both handlers on every event on purpose: which facts open a cadence and
|
|
3082
|
+
// which close it is the operator's choice, not ours. A house may well start
|
|
3083
|
+
// a win-back flow on `crm.deal_lost`.
|
|
3084
|
+
//
|
|
3085
|
+
// Written out one by one rather than generated from CADENCE_FACTS. The
|
|
3086
|
+
// generated form read better and was invisible to `check-event-contract`,
|
|
3087
|
+
// whose regex needs a literal `subscribesTo: [ … ]` with literal event and
|
|
3088
|
+
// handler strings — so fourteen subscriptions that nothing registered
|
|
3089
|
+
// passed the gate in silence. A contract a checker cannot read is not a
|
|
3090
|
+
// contract.
|
|
3091
|
+
subscribesTo: [
|
|
3092
|
+
{ event: "crm.lead_created", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by a new lead", maxAttempts: 5 },
|
|
3093
|
+
{ event: "crm.lead_created", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on a new lead", maxAttempts: 5 },
|
|
3094
|
+
{ event: "crm.stage_changed", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by a stage change", maxAttempts: 5 },
|
|
3095
|
+
{ event: "crm.stage_changed", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on a stage change", maxAttempts: 5 },
|
|
3096
|
+
{ event: "crm.quote_sent", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by a sent quote", maxAttempts: 5 },
|
|
3097
|
+
{ event: "crm.quote_sent", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on a sent quote", maxAttempts: 5 },
|
|
3098
|
+
{ event: "crm.quote_approved", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by an approved quote", maxAttempts: 5 },
|
|
3099
|
+
{ event: "crm.quote_approved", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on an approved quote", maxAttempts: 5 },
|
|
3100
|
+
{ event: "crm.deal_won", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by a won deal", maxAttempts: 5 },
|
|
3101
|
+
{ event: "crm.deal_won", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on a won deal", maxAttempts: 5 },
|
|
3102
|
+
{ event: "crm.deal_lost", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by a lost deal", maxAttempts: 5 },
|
|
3103
|
+
{ event: "crm.deal_lost", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on a lost deal", maxAttempts: 5 },
|
|
3104
|
+
{ event: "crm.deal_idle", handler: "plg_automations_on_entry_event", description: "Enrol into flows entered by an idle deal", maxAttempts: 5 },
|
|
3105
|
+
{ event: "crm.deal_idle", handler: "plg_automations_on_exit_event", description: "Leave flows that exit on an idle deal", maxAttempts: 5 }
|
|
3106
|
+
],
|
|
3107
|
+
// The barrel an app installing this plugin applies. Without it the tables
|
|
3108
|
+
// and functions below simply do not exist there: `chain.json` provisions a
|
|
3109
|
+
// CLUSTER, and the manifest is what provisions an app — this plugin had
|
|
3110
|
+
// neither a `migrations` key nor a barrel to point it at, so fourteen
|
|
3111
|
+
// subscriptions and a whole screen shipped over nothing.
|
|
3112
|
+
migrations: MIGRATIONS.map((m) => ({
|
|
3113
|
+
id: `automations-${m.id}`,
|
|
3114
|
+
version: "1.0.0",
|
|
3115
|
+
sql: m.sql,
|
|
3116
|
+
description: m.id
|
|
3117
|
+
})),
|
|
3118
|
+
locales: automationsLocales,
|
|
1587
3119
|
widgets: []
|
|
1588
3120
|
};
|
|
1589
3121
|
}
|