@cryptotaxi247 / CoPilot / commits / 9b39904d

feat: Shuffle MCP integration — per-customer notification routing (#830)

* docs(shuffle): add planning doc for per-customer notification routing Architecture sketch + 4-phase rollout plan for embedding Shuffle's hosted MCP layer behind CoPilot for outbound notifications. No code yet — this commit exists so the branch can host the planning conversation. Phase 1: schema + manual webhooks/SMTP, no Shuffle dep Phase 2: shuffle-mcp stdio server in Talon, agent calls hosted MCP Phase 3: <ShuffleMCP> picker in CoPilot frontend, OAuth handoff Phase 4: per-channel templates, anonymize, retry, rate limit Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(shuffle): refine Phase 1 schema + reframe tenant isolation Round of edits from the design conversation on PR #830: - Replace abstract "schema columns" bullet with full SQLModel definitions for both `customer_notification_routes` and `notification_dispatch_log` - Add `name`, `last_dispatched_at`, `dispatch_count`, `created_by` to the routes table (UI readability + audit + denorm for the list view) - Add `latency_ms` and `payload_preview` to the dispatch log (debugging) - Drop the `anonymize` column entirely — recipients are SOC analysts who already see deanonymized reports in the CoPilot UI; the toggle has no consumer - Reframe the tenant-isolation cross-cutting note: the Shuffle MCP itself is a stateless adapter (same `/apps/slack` URL for every customer); the isolation boundary lives in CoPilot's DB at `customer_shuffle_integrations` + `customer_notification_routes` lookup time, scoped by `customer_code` - Drop the anonymize discussion from Phase 4 + open questions; pricing removed from open questions per "not blocking right now" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(notifications): Phase 1 — per-customer notification routing (Slack + SMTP) Adds the foundation for per-customer notification fan-out from Talon's investigation reports. Phase 1 ships direct webhook/SMTP delivery — no Shuffle dependency yet. Phase 2 (planned in docs/architecture/SHUFFLE_NOTIFICATIONS.md) will layer Shuffle's hosted MCP catalog on top of this same schema. ## Backend Two new SQLModel tables in app/db/universal_models.py (user runs the Alembic migration manually): - `customer_notification_route` — per-customer routing rules (name, trigger, channel, destination, min_severity, enabled, denorm dispatch_count + last_dispatched_at, audit columns) - `notification_dispatch_log` — append-only audit + idempotency table with a unique constraint on (customer_code, alert_id, route_id, trigger) New module `app/notifications/`: - `schema/notifications.py` — Pydantic input/output shapes; trigger, channel, severity, status enums for API-boundary validation - `services/dispatchers.py` — async helpers for Slack incoming-webhook POST and SMTP send (env-driven SMTP config: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS) - `services/notifications.py` — CRUD for routes, the dispatch loop (route lookup → severity/trigger filter → channel dispatch → log), and the read-only dispatch log query - `routes/notifications.py` — REST routes, all admin/analyst-scoped: GET/POST /api/customers/{code}/notification_routes PATCH/DELETE /api/customers/{code}/notification_routes/{id} GET /api/customers/{code}/notification_dispatch_log POST /api/notifications/dispatch ← Talon calls this The dispatch loop is best-effort: dispatcher exceptions never bubble out of the route handler, every outcome lands in the log table, and the unique-key constraint short-circuits re-runs. ## Frontend New "AI Notifications" tab on the Customer detail page with two sub-tabs: Routes (CRUD list + form) and Dispatch log (read-only audit table). Slack webhook URLs are masked in the list view to reduce shoulder-surf risk; full URL is only visible in the edit form. - `types/notifications.d.ts`, `api/endpoints/notifications.ts` - `components/customers/aiNotifications/{CustomerAiNotifications, CustomerAiNotificationRoutes, CustomerAiNotificationRouteItem, CustomerAiNotificationRouteForm, CustomerAiNotificationDispatchLog}.vue` ## Migration The Alembic migration is intentionally NOT included in this commit — the user runs migrations manually per their workflow. ## Talon Talon-side changes (groups/copilot/notifications.md + a one-line ref from groups/copilot/CLAUDE.md as step 6d) ship in a matching branch on the nanoclaw repo so the prompt change is reviewable separately from the CoPilot wiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(notifications): drop slack_webhook from Phase 1 — SMTP only Phase 1's stated goal is "validate the schema, dispatch loop, idempotency, and agent instruction without a Shuffle dependency." Both SMTP and Slack incoming-webhooks satisfy that — but Phase 2's Shuffle picker replaces the manual webhook-URL paste UX entirely (the user OAuths Slack once via Shuffle and Talon dispatches via the hosted MCP). Asking customers to paste raw webhook URLs into CoPilot now is throwaway UX. SMTP stays in Phase 1 because it's server-side env config, not per- customer auth — completely different problem space, not redundant with Shuffle. SMTP-only delivery exercises the same dispatch loop, log idempotency, and agent instruction. Changes: - Drop SLACK_WEBHOOK from NotificationChannel enum in schema - Drop dispatch_slack_webhook() and httpx import from dispatchers - Remove the Slack branch from the dispatch loop's channel switch (the unsupported-channel fall-through stays — it'll catch any legacy rows in the DB and any future channel like 'shuffle' that hits before its dispatcher arm exists) - Frontend: NotificationChannel union narrowed to "smtp_email"; the channel select is now a single-option control with a footnote explaining that Slack/Teams/etc. arrive in Phase 2 via Shuffle - Drop the Slack-URL masking logic from the route item card — email recipients aren't secrets the way webhook URLs are - Update planning doc's Phase 1 acceptance criteria to use SMTP - Update Talon's notifications.md example to use SMTP The Alembic migration's `channel` column is still a varchar — no schema change needed. Phase 2 will simply add 'shuffle' as a valid value at the application layer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(notifications): add customer notification routing and dispatch log tables * feat(notifications): Phase 2a — Shuffle dispatch + per-customer integrations Adds the Shuffle channel to CoPilot's notification dispatch loop. Customers' Slack / Outlook / Teams / etc. notifications go through their authenticated Shuffle org via the deployment's admin Bearer + per-customer Org-Id. SMTP path from Phase 1 is preserved. ## Schema (backend/app/db/universal_models.py + alembic v2) - New table `customer_shuffle_integration` (per-customer Org-Id, display_name, enabled, last_used_at) - Columns added to `customer_notification_route`: `shuffle_integration_id` FK, `shuffle_app_id`, `shuffle_app_name` - Column added to `notification_dispatch_log`: `shuffle_execution_id` (for forensic correlation back to Shuffle's UI) ## Backend - New schemas: NotificationChannel.SHUFFLE, ShuffleIntegration{Create, Update,Read,ListResponse,Response}, ShuffleApp{,ListResponse}, ShuffleVerifyResponse; route validators enforce shuffle_integration_id + shuffle_app_id when channel='shuffle' - New dispatcher `dispatch_shuffle()` POSTs to `https://shuffler.io/api/v1/apps/{app_id}/mcp` with the Shuffle connector's Bearer + customer's Org-Id; fire-and-record (no polling for downstream terminal state); 30s timeout (vs SMTP's 10s) - New helper `list_shuffle_apps()` populates the route form's app picker via `GET /api/v1/apps` - Service-layer additions: list/get/create/update/delete for shuffle integrations, list_apps_for_integration, verify_integration; tenant- boundary enforcer `_ensure_integration_belongs_to_customer` - New routes: GET/POST/PATCH/DELETE `/customers/{code}/shuffle_integrations`, GET `.../{id}/apps`, GET `.../{id}/verify` - `dispatch_shuffle` connector lookup is gated by "is any matched route Shuffle?" so SMTP-only customers never touch the connector row ## Frontend - New `<CustomerShuffleIntegrations>` sub-tab with form + list + verify button (button color/icon flip on probe success/failure) - Route form: third channel option "Shuffle"; integration picker; app picker auto-populated from Shuffle's catalog; destination hint field replaces the SMTP-only recipient field; per-channel form rules - Route item card: shows underlying Shuffle app name when channel=shuffle ## Bugfixes from smoke testing - Drop `back_populates` on the route↔log and integration↔route Relationship() pairs — the implicit parent-collection sync during flush() was firing synchronous SELECT-by-PK loads that throw MissingGreenlet under AsyncSession - `_record_log` rewritten to query-then-update-or-insert instead of insert-then-rollback. Previously, a failed dispatch's log row blocked every retry via the unique constraint (IntegrityError → rollback → rollback expired the route → outcome construction's `route.id` access fired implicit refresh → MissingGreenlet). Now: existing `sent` rows are true idempotency hits (skip), existing `failed`/ `skipped` rows are overwritten with the new outcome (retry path works), missing rows insert fresh. - Cache route + integration attributes into locals at the top of the per-route loop. Defends against any future post-await expiration — pure-Python access on locals can't trigger lazy loads. - Use explicit UPDATE statements for `dispatch_count` / `last_dispatched_at` / `last_used_at` denorm columns instead of mutating loaded ORM objects, eliminating one more class of stale- state risk. Phase 2b (Shuffle MCP wired into Talon for interactive agent work) and Phase 3 (embedded `<ShuffleMCP>` picker replacing the manual Org-Id paste) follow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * precommit fixes * fix(notifications): update trailing comma in multiple locations for consistency --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

taylor_socfortress committed Apr 29, 2026 at 15:14 UTC 9b39904d59f1390d93d65073e7a2b2f366a827ac
27 files changed +3962 -1
.pre-commit-config.yaml
+1 -1
@@ -37,7 +37,7 @@ repos:
37 - id: setup-cfg-fmt
38
39 - repo: https://github.com/asottile/add-trailing-comma
40 - rev: v3.0.0
40 + rev: v3.2.0
41 hooks:
42 - id: add-trailing-comma
43
backend/alembic/versions/260371af0a48_add_notification_tablesv2.py new
+68
@@ -0,0 +1,68 @@
1 +"""Add notification tablesv2
2 +
3 +Revision ID: 260371af0a48
4 +Revises: 458e2a7b6b11
5 +Create Date: 2026-04-29 11:17:25.975308
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "260371af0a48"
17 +down_revision: Union[str, None] = "458e2a7b6b11"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "customer_shuffle_integration",
26 + sa.Column("id", sa.Integer(), nullable=False),
27 + sa.Column("customer_code", sa.String(length=64), nullable=False),
28 + sa.Column("shuffle_org_id", sa.String(length=64), nullable=False),
29 + sa.Column("display_name", sa.String(length=128), nullable=False),
30 + sa.Column("enabled", sa.Boolean(), nullable=False),
31 + sa.Column("last_used_at", sa.DateTime(), nullable=True),
32 + sa.Column("created_by", sa.String(length=128), nullable=True),
33 + sa.Column("created_at", sa.DateTime(), nullable=False),
34 + sa.Column("updated_at", sa.DateTime(), nullable=True),
35 + sa.ForeignKeyConstraint(
36 + ["customer_code"],
37 + ["customers.customer_code"],
38 + ),
39 + sa.PrimaryKeyConstraint("id"),
40 + )
41 + op.create_index(op.f("ix_customer_shuffle_integration_created_at"), "customer_shuffle_integration", ["created_at"], unique=False)
42 + op.create_index(op.f("ix_customer_shuffle_integration_customer_code"), "customer_shuffle_integration", ["customer_code"], unique=False)
43 + op.add_column("customer_notification_route", sa.Column("shuffle_integration_id", sa.Integer(), nullable=True))
44 + op.add_column("customer_notification_route", sa.Column("shuffle_app_id", sa.String(length=64), nullable=True))
45 + op.add_column("customer_notification_route", sa.Column("shuffle_app_name", sa.String(length=128), nullable=True))
46 + op.create_index(
47 + op.f("ix_customer_notification_route_shuffle_integration_id"),
48 + "customer_notification_route",
49 + ["shuffle_integration_id"],
50 + unique=False,
51 + )
52 + op.create_foreign_key(None, "customer_notification_route", "customer_shuffle_integration", ["shuffle_integration_id"], ["id"])
53 + op.add_column("notification_dispatch_log", sa.Column("shuffle_execution_id", sa.String(length=128), nullable=True))
54 + # ### end Alembic commands ###
55 +
56 +
57 +def downgrade() -> None:
58 + # ### commands auto generated by Alembic - please adjust! ###
59 + op.drop_column("notification_dispatch_log", "shuffle_execution_id")
60 + op.drop_constraint(None, "customer_notification_route", type_="foreignkey")
61 + op.drop_index(op.f("ix_customer_notification_route_shuffle_integration_id"), table_name="customer_notification_route")
62 + op.drop_column("customer_notification_route", "shuffle_app_name")
63 + op.drop_column("customer_notification_route", "shuffle_app_id")
64 + op.drop_column("customer_notification_route", "shuffle_integration_id")
65 + op.drop_index(op.f("ix_customer_shuffle_integration_customer_code"), table_name="customer_shuffle_integration")
66 + op.drop_index(op.f("ix_customer_shuffle_integration_created_at"), table_name="customer_shuffle_integration")
67 + op.drop_table("customer_shuffle_integration")
68 + # ### end Alembic commands ###
backend/alembic/versions/458e2a7b6b11_add_notification_tables.py new
+88
@@ -0,0 +1,88 @@
1 +"""Add notification tables
2 +
3 +Revision ID: 458e2a7b6b11
4 +Revises: 03508fb56a48
5 +Create Date: 2026-04-29 10:40:19.212448
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "458e2a7b6b11"
17 +down_revision: Union[str, None] = "03508fb56a48"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "customer_notification_route",
26 + sa.Column("destination", sa.Text(), nullable=True),
27 + sa.Column("format_template", sa.Text(), nullable=True),
28 + sa.Column("id", sa.Integer(), nullable=False),
29 + sa.Column("customer_code", sa.String(length=64), nullable=False),
30 + sa.Column("name", sa.String(length=128), nullable=False),
31 + sa.Column("trigger", sa.String(length=64), nullable=False),
32 + sa.Column("channel", sa.String(length=32), nullable=False),
33 + sa.Column("min_severity", sa.String(length=20), nullable=False),
34 + sa.Column("enabled", sa.Boolean(), nullable=False),
35 + sa.Column("last_dispatched_at", sa.DateTime(), nullable=True),
36 + sa.Column("dispatch_count", sa.Integer(), nullable=False),
37 + sa.Column("created_by", sa.String(length=128), nullable=True),
38 + sa.Column("created_at", sa.DateTime(), nullable=False),
39 + sa.Column("updated_at", sa.DateTime(), nullable=True),
40 + sa.ForeignKeyConstraint(
41 + ["customer_code"],
42 + ["customers.customer_code"],
43 + ),
44 + sa.PrimaryKeyConstraint("id"),
45 + )
46 + op.create_index(op.f("ix_customer_notification_route_created_at"), "customer_notification_route", ["created_at"], unique=False)
47 + op.create_index(op.f("ix_customer_notification_route_customer_code"), "customer_notification_route", ["customer_code"], unique=False)
48 + op.create_index(op.f("ix_customer_notification_route_trigger"), "customer_notification_route", ["trigger"], unique=False)
49 + op.create_table(
50 + "notification_dispatch_log",
51 + sa.Column("error_message", sa.Text(), nullable=True),
52 + sa.Column("payload_preview", sa.Text(), nullable=True),
53 + sa.Column("id", sa.Integer(), nullable=False),
54 + sa.Column("customer_code", sa.String(length=64), nullable=False),
55 + sa.Column("alert_id", sa.Integer(), nullable=False),
56 + sa.Column("route_id", sa.Integer(), nullable=False),
57 + sa.Column("trigger", sa.String(length=64), nullable=False),
58 + sa.Column("dispatched_at", sa.DateTime(), nullable=False),
59 + sa.Column("status", sa.String(length=16), nullable=False),
60 + sa.Column("latency_ms", sa.Integer(), nullable=True),
61 + sa.ForeignKeyConstraint(
62 + ["route_id"],
63 + ["customer_notification_route.id"],
64 + ),
65 + sa.PrimaryKeyConstraint("id"),
66 + sa.UniqueConstraint("customer_code", "alert_id", "route_id", "trigger", name="uq_notif_dispatch_idem"),
67 + )
68 + op.create_index(op.f("ix_notification_dispatch_log_alert_id"), "notification_dispatch_log", ["alert_id"], unique=False)
69 + op.create_index(op.f("ix_notification_dispatch_log_customer_code"), "notification_dispatch_log", ["customer_code"], unique=False)
70 + op.create_index(op.f("ix_notification_dispatch_log_dispatched_at"), "notification_dispatch_log", ["dispatched_at"], unique=False)
71 + op.create_index(op.f("ix_notification_dispatch_log_route_id"), "notification_dispatch_log", ["route_id"], unique=False)
72 + op.create_index(op.f("ix_notification_dispatch_log_status"), "notification_dispatch_log", ["status"], unique=False)
73 + # ### end Alembic commands ###
74 +
75 +
76 +def downgrade() -> None:
77 + # ### commands auto generated by Alembic - please adjust! ###
78 + op.drop_index(op.f("ix_notification_dispatch_log_status"), table_name="notification_dispatch_log")
79 + op.drop_index(op.f("ix_notification_dispatch_log_route_id"), table_name="notification_dispatch_log")
80 + op.drop_index(op.f("ix_notification_dispatch_log_dispatched_at"), table_name="notification_dispatch_log")
81 + op.drop_index(op.f("ix_notification_dispatch_log_customer_code"), table_name="notification_dispatch_log")
82 + op.drop_index(op.f("ix_notification_dispatch_log_alert_id"), table_name="notification_dispatch_log")
83 + op.drop_table("notification_dispatch_log")
84 + op.drop_index(op.f("ix_customer_notification_route_trigger"), table_name="customer_notification_route")
85 + op.drop_index(op.f("ix_customer_notification_route_customer_code"), table_name="customer_notification_route")
86 + op.drop_index(op.f("ix_customer_notification_route_created_at"), table_name="customer_notification_route")
87 + op.drop_table("customer_notification_route")
88 + # ### end Alembic commands ###
backend/app/db/universal_models.py
+189
@@ -697,3 +697,192 @@ class AiAnalystPalaceLesson(SQLModel, table=True):
697
698 review: Optional["AiAnalystReview"] = Relationship(back_populates="palace_lessons")
699 customer: Optional["Customers"] = Relationship()
700 +
701 +
702 +# ---------------------------------------------------------------------------
703 +# Notification routing
704 +#
705 +# Per-customer "where do we tell someone about an investigation result"
706 +# config. Phase 1 ships with two delivery channels — Slack incoming
707 +# webhooks and SMTP email — and is intentionally provider-direct (no
708 +# Shuffle dependency yet). Phase 2 adds a `customer_shuffle_integrations`
709 +# table and an `integration_id` FK on the routes table to layer Shuffle's
710 +# 3,000+ app catalog on top, without breaking the Phase 1 routes.
711 +#
712 +# Triggers and severities are stored as plain strings (not enums) on
713 +# purpose — adding a new trigger or severity tier later is a data-only
714 +# change, no migration. The CRUD layer enforces the v1 set.
715 +# ---------------------------------------------------------------------------
716 +
717 +
718 +class CustomerNotificationRoute(SQLModel, table=True):
719 + __tablename__ = "customer_notification_route"
720 +
721 + id: Optional[int] = Field(primary_key=True)
722 + customer_code: str = Field(
723 + foreign_key="customers.customer_code",
724 + max_length=64,
725 + index=True,
726 + nullable=False,
727 + )
728 +
729 + # Human label shown in the UI list. Without this, users would have to
730 + # mentally parse channel+destination columns to identify a rule.
731 + name: str = Field(max_length=128, nullable=False)
732 +
733 + # 'investigation_complete' (any successful investigation, regardless
734 + # of verdict) or 'severity_critical_or_high' (Critical/High only).
735 + # Stored as string so adding new triggers later is a data-only change.
736 + trigger: str = Field(max_length=64, nullable=False, index=True)
737 +
738 + # 'slack_webhook' or 'smtp_email' for Phase 1. Phase 2 adds 'shuffle'
739 + # and pairs with an integration_id FK.
740 + channel: str = Field(max_length=32, nullable=False)
741 +
742 + # Slack incoming-webhook URL or SMTP recipient email address
743 + # (multi-recipient = comma separated, normalized in the service).
744 + destination: str = Field(sa_column=Column(Text), nullable=False)
745 +
746 + # 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational'. Inclusive
747 + # — a 'High' route fires on Critical and High.
748 + min_severity: str = Field(max_length=20, nullable=False, default="Medium")
749 +
750 + # Optional Jinja-style override for the dispatched message body.
751 + # Default templates live in the service layer; this lets a customer
752 + # tune wording without a code change. Phase 4 ships the polished
753 + # default set; Phase 1 ships a no-frills fallback.
754 + format_template: Optional[str] = Field(sa_column=Column(Text), default=None)
755 +
756 + enabled: bool = Field(default=True, nullable=False)
757 +
758 + # Denormalized for the UI list so we can show "fired 2h ago" without
759 + # joining the dispatch log on every render. Maintained by the
760 + # dispatch service.
761 + last_dispatched_at: Optional[datetime] = Field(default=None)
762 + dispatch_count: int = Field(default=0, nullable=False)
763 +
764 + # CoPilot user who created the route — audit trail for change
765 + # management. Populated from the auth context in the route handler.
766 + created_by: Optional[str] = Field(default=None, max_length=128)
767 +
768 + # ----- Phase 2: Shuffle channel routing -----
769 + # Populated when channel='shuffle'. NULL for legacy SMTP routes.
770 + # The (integration_id, app_id, app_name) triple together describes
771 + # "which Shuffle org" + "which app inside that org" + "label for the
772 + # UI." `app_id` is the Shuffle app UUID we POST to
773 + # /api/v1/apps/{app_id}/mcp; `app_name` is the human-readable label
774 + # (e.g. "Slack") we cache so the UI doesn't have to roundtrip to
775 + # Shuffle to render the route list.
776 + shuffle_integration_id: Optional[int] = Field(
777 + default=None,
778 + foreign_key="customer_shuffle_integration.id",
779 + index=True,
780 + )
781 + shuffle_app_id: Optional[str] = Field(default=None, max_length=64)
782 + shuffle_app_name: Optional[str] = Field(default=None, max_length=128)
783 +
784 + created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
785 + updated_at: Optional[datetime] = Field(default=None)
786 +
787 + customer: Optional["Customers"] = Relationship()
788 + # NB: no `back_populates` on the reverse relationships below. The
789 + # dispatch service never traverses these — but with back_populates
790 + # configured, SQLAlchemy fires implicit synchronous loads on the
791 + # parent collections during flush() to keep the in-session graph in
792 + # sync, which throws MissingGreenlet under AsyncSession. One-way
793 + # foreign keys are fine here; we walk them via explicit queries
794 + # (`session.get(...)`) when we need them.
795 + dispatches: list["NotificationDispatchLog"] = Relationship()
796 + shuffle_integration: Optional["CustomerShuffleIntegration"] = Relationship()
797 +
798 +
799 +class NotificationDispatchLog(SQLModel, table=True):
800 + __tablename__ = "notification_dispatch_log"
801 + __table_args__ = (
802 + # Idempotency key: re-running the same investigation must not
803 + # re-fire the same notification. The dispatch service does
804 + # "INSERT ... ON CONFLICT DO NOTHING" against this constraint and
805 + # short-circuits if a row already exists.
806 + UniqueConstraint(
807 + "customer_code",
808 + "alert_id",
809 + "route_id",
810 + "trigger",
811 + name="uq_notif_dispatch_idem",
812 + ),
813 + )
814 +
815 + id: Optional[int] = Field(primary_key=True)
816 + customer_code: str = Field(max_length=64, index=True, nullable=False)
817 + alert_id: int = Field(nullable=False, index=True)
818 + route_id: int = Field(
819 + foreign_key="customer_notification_route.id",
820 + nullable=False,
821 + index=True,
822 + )
823 + trigger: str = Field(max_length=64, nullable=False)
824 +
825 + dispatched_at: datetime = Field(default_factory=datetime.utcnow, index=True)
826 + # 'sent' | 'failed' | 'skipped' (skipped = filter mismatch reached
827 + # the log path, e.g. a route whose enabled=false flipped during a
828 + # batch). Phase 4 retry semantics will add 'retrying'.
829 + status: str = Field(max_length=16, nullable=False, index=True)
830 + error_message: Optional[str] = Field(sa_column=Column(Text), default=None)
831 + # Wall-clock latency of the underlying provider call (Slack POST or
832 + # SMTP send), excluding our own DB work. Useful for spotting flaky
833 + # webhooks before they become a customer complaint.
834 + latency_ms: Optional[int] = Field(default=None)
835 + # First 500 chars of the formatted body. Stored for debugging — when
836 + # a customer says "the message looked wrong" we want to see what we
837 + # actually sent without storing the entire body history.
838 + payload_preview: Optional[str] = Field(sa_column=Column(Text), default=None)
839 + # Phase 2: Shuffle's POST /apps/{id}/mcp returns a fire-and-record
840 + # execution_id. Stored here so an admin can pivot from "this
841 + # notification didn't arrive at Slack" → look up the run in
842 + # shuffler.io's UI to see whether Shuffle accepted the dispatch but
843 + # the downstream app rejected it. Null for non-Shuffle channels.
844 + shuffle_execution_id: Optional[str] = Field(default=None, max_length=128)
845 +
846 + # See note on CustomerNotificationRoute.dispatches — back_populates
847 + # removed deliberately to keep AsyncSession flush() synchronous-IO-free.
848 + route: Optional["CustomerNotificationRoute"] = Relationship()
849 +
850 +
851 +class CustomerShuffleIntegration(SQLModel, table=True):
852 + __tablename__ = "customer_shuffle_integration"
853 +
854 + id: Optional[int] = Field(primary_key=True)
855 + customer_code: str = Field(
856 + foreign_key="customers.customer_code",
857 + max_length=64,
858 + index=True,
859 + nullable=False,
860 + )
861 +
862 + # The customer's Shuffle Org-Id. SOCfortress's deployment-wide
863 + # `SHUFFLE_API_KEY` (admin-scoped, lives in the connectors table)
864 + # has access to every org; the per-customer differentiator is this
865 + # Org-Id, sent as the `Org-Id` header on each dispatch so Shuffle
866 + # routes the call to the correct org's authenticated apps. Stored
867 + # opaquely as a string — Shuffle uses a UUID format today but we
868 + # don't depend on that.
869 + shuffle_org_id: str = Field(max_length=64, nullable=False)
870 +
871 + # Human label, e.g. "Acme Production Shuffle". Surfaced in the
872 + # CoPilot UI's integration picker; not sent to Shuffle.
873 + display_name: str = Field(max_length=128, nullable=False)
874 +
875 + enabled: bool = Field(default=True, nullable=False)
876 + # Updated by the dispatch service whenever a route referencing this
877 + # integration successfully fires — useful for spotting integrations
878 + # that are configured but never used.
879 + last_used_at: Optional[datetime] = Field(default=None)
880 +
881 + created_by: Optional[str] = Field(default=None, max_length=128)
882 + created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
883 + updated_at: Optional[datetime] = Field(default=None)
884 +
885 + customer: Optional["Customers"] = Relationship()
886 + # See note on CustomerNotificationRoute.dispatches — back_populates
887 + # removed for AsyncSession compatibility.
888 + routes: list["CustomerNotificationRoute"] = Relationship()
backend/app/notifications/__init__.py
backend/app/notifications/routes/__init__.py
backend/app/notifications/routes/notifications.py new
+302
@@ -0,0 +1,302 @@
1 +"""
2 +REST routes for the notification routing module.
3 +
4 +Two surfaces:
5 +
6 + /customers/{customer_code}/notification_routes
7 + /customers/{customer_code}/notification_dispatch_log
8 + admin/analyst CRUD + read-only audit view, used by the CoPilot
9 + frontend's per-customer Notifications tab.
10 +
11 + /notifications/dispatch
12 + called by Talon (NanoClaw) after every successful investigation.
13 + Walks the customer's routes, fires each match, logs each
14 + outcome, returns a per-route result list. Best-effort — Talon
15 + does not retry, and a failure here MUST NOT fail the upstream
16 + investigation.
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +from fastapi import APIRouter
22 +from fastapi import Depends
23 +from fastapi import Security
24 +from loguru import logger
25 +from sqlalchemy.ext.asyncio import AsyncSession
26 +
27 +from app.auth.models.users import User
28 +from app.auth.utils import AuthHandler
29 +from app.db.db_session import get_db
30 +from app.notifications.schema.notifications import DispatchLogListResponse
31 +from app.notifications.schema.notifications import DispatchRequest
32 +from app.notifications.schema.notifications import DispatchResponse
33 +from app.notifications.schema.notifications import NotificationRouteCreate
34 +from app.notifications.schema.notifications import NotificationRouteListResponse
35 +from app.notifications.schema.notifications import NotificationRouteRead
36 +from app.notifications.schema.notifications import NotificationRouteResponse
37 +from app.notifications.schema.notifications import NotificationRouteUpdate
38 +from app.notifications.schema.notifications import ShuffleAppListResponse
39 +from app.notifications.schema.notifications import ShuffleIntegrationCreate
40 +from app.notifications.schema.notifications import ShuffleIntegrationListResponse
41 +from app.notifications.schema.notifications import ShuffleIntegrationRead
42 +from app.notifications.schema.notifications import ShuffleIntegrationResponse
43 +from app.notifications.schema.notifications import ShuffleIntegrationUpdate
44 +from app.notifications.schema.notifications import ShuffleVerifyResponse
45 +from app.notifications.services import notifications as svc
46 +
47 +notifications_router = APIRouter()
48 +
49 +
50 +# ---------------------------------------------------------------------------
51 +# Per-customer route CRUD
52 +# ---------------------------------------------------------------------------
53 +
54 +
55 +@notifications_router.get(
56 + "/customers/{customer_code}/notification_routes",
57 + response_model=NotificationRouteListResponse,
58 + description="List notification routes for a customer.",
59 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
60 +)
61 +async def list_routes_route(
62 + customer_code: str,
63 + session: AsyncSession = Depends(get_db),
64 +) -> NotificationRouteListResponse:
65 + routes = await svc.list_routes(customer_code, session)
66 + return NotificationRouteListResponse(
67 + success=True,
68 + message=f"{len(routes)} route(s) retrieved",
69 + routes=[NotificationRouteRead.from_orm(r) for r in routes],
70 + )
71 +
72 +
73 +@notifications_router.post(
74 + "/customers/{customer_code}/notification_routes",
75 + response_model=NotificationRouteResponse,
76 + description="Create a new notification route for a customer.",
77 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
78 +)
79 +async def create_route_route(
80 + customer_code: str,
81 + payload: NotificationRouteCreate,
82 + session: AsyncSession = Depends(get_db),
83 + current_user: User = Depends(AuthHandler().get_current_user),
84 +) -> NotificationRouteResponse:
85 + logger.info(f"User {current_user.id} creating notification route " f"for customer {customer_code}")
86 + route = await svc.create_route(
87 + customer_code=customer_code,
88 + payload=payload,
89 + created_by=getattr(current_user, "username", None) or str(current_user.id),
90 + session=session,
91 + )
92 + return NotificationRouteResponse(
93 + success=True,
94 + message="Route created",
95 + route=NotificationRouteRead.from_orm(route),
96 + )
97 +
98 +
99 +@notifications_router.patch(
100 + "/customers/{customer_code}/notification_routes/{route_id}",
101 + response_model=NotificationRouteResponse,
102 + description="Update an existing notification route. Only fields included in the body are modified.",
103 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
104 +)
105 +async def update_route_route(
106 + customer_code: str,
107 + route_id: int,
108 + payload: NotificationRouteUpdate,
109 + session: AsyncSession = Depends(get_db),
110 +) -> NotificationRouteResponse:
111 + route = await svc.update_route(route_id, customer_code, payload, session)
112 + return NotificationRouteResponse(
113 + success=True,
114 + message="Route updated",
115 + route=NotificationRouteRead.from_orm(route),
116 + )
117 +
118 +
119 +@notifications_router.delete(
120 + "/customers/{customer_code}/notification_routes/{route_id}",
121 + description="Delete a notification route. Dispatch log entries for the route are retained.",
122 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
123 +)
124 +async def delete_route_route(
125 + customer_code: str,
126 + route_id: int,
127 + session: AsyncSession = Depends(get_db),
128 +) -> dict:
129 + await svc.delete_route(route_id, customer_code, session)
130 + return {"success": True, "message": "Route deleted"}
131 +
132 +
133 +# ---------------------------------------------------------------------------
134 +# Dispatch log (read-only audit)
135 +# ---------------------------------------------------------------------------
136 +
137 +
138 +@notifications_router.get(
139 + "/customers/{customer_code}/notification_dispatch_log",
140 + response_model=DispatchLogListResponse,
141 + description="Recent notification dispatch attempts for a customer (newest first, capped at 100).",
142 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
143 +)
144 +async def list_dispatch_log_route(
145 + customer_code: str,
146 + session: AsyncSession = Depends(get_db),
147 +) -> DispatchLogListResponse:
148 + entries = await svc.list_dispatch_log(customer_code, session, limit=100)
149 + return DispatchLogListResponse(
150 + success=True,
151 + message=f"{len(entries)} entry/entries retrieved",
152 + entries=entries,
153 + )
154 +
155 +
156 +# ---------------------------------------------------------------------------
157 +# Per-customer Shuffle integrations (Phase 2)
158 +# ---------------------------------------------------------------------------
159 +
160 +
161 +@notifications_router.get(
162 + "/customers/{customer_code}/shuffle_integrations",
163 + response_model=ShuffleIntegrationListResponse,
164 + description="List Shuffle integrations (per-customer Org-Id rows) for a customer.",
165 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
166 +)
167 +async def list_shuffle_integrations_route(
168 + customer_code: str,
169 + session: AsyncSession = Depends(get_db),
170 +) -> ShuffleIntegrationListResponse:
171 + integrations = await svc.list_shuffle_integrations(customer_code, session)
172 + return ShuffleIntegrationListResponse(
173 + success=True,
174 + message=f"{len(integrations)} integration(s) retrieved",
175 + integrations=[ShuffleIntegrationRead.from_orm(i) for i in integrations],
176 + )
177 +
178 +
179 +@notifications_router.post(
180 + "/customers/{customer_code}/shuffle_integrations",
181 + response_model=ShuffleIntegrationResponse,
182 + description="Create a new Shuffle integration for a customer (records the customer's Shuffle Org-Id).",
183 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
184 +)
185 +async def create_shuffle_integration_route(
186 + customer_code: str,
187 + payload: ShuffleIntegrationCreate,
188 + session: AsyncSession = Depends(get_db),
189 + current_user: User = Depends(AuthHandler().get_current_user),
190 +) -> ShuffleIntegrationResponse:
191 + logger.info(f"User {current_user.id} adding Shuffle integration " f"({payload.display_name}) for customer {customer_code}")
192 + integration = await svc.create_shuffle_integration(
193 + customer_code=customer_code,
194 + payload=payload,
195 + created_by=getattr(current_user, "username", None) or str(current_user.id),
196 + session=session,
197 + )
198 + return ShuffleIntegrationResponse(
199 + success=True,
200 + message="Integration created",
201 + integration=ShuffleIntegrationRead.from_orm(integration),
202 + )
203 +
204 +
205 +@notifications_router.patch(
206 + "/customers/{customer_code}/shuffle_integrations/{integration_id}",
207 + response_model=ShuffleIntegrationResponse,
208 + description="Update an existing Shuffle integration. Only fields included in the body are modified.",
209 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
210 +)
211 +async def update_shuffle_integration_route(
212 + customer_code: str,
213 + integration_id: int,
214 + payload: ShuffleIntegrationUpdate,
215 + session: AsyncSession = Depends(get_db),
216 +) -> ShuffleIntegrationResponse:
217 + integration = await svc.update_shuffle_integration(integration_id, customer_code, payload, session)
218 + return ShuffleIntegrationResponse(
219 + success=True,
220 + message="Integration updated",
221 + integration=ShuffleIntegrationRead.from_orm(integration),
222 + )
223 +
224 +
225 +@notifications_router.delete(
226 + "/customers/{customer_code}/shuffle_integrations/{integration_id}",
227 + description="Delete a Shuffle integration. Refused if any notification routes reference it.",
228 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
229 +)
230 +async def delete_shuffle_integration_route(
231 + customer_code: str,
232 + integration_id: int,
233 + session: AsyncSession = Depends(get_db),
234 +) -> dict:
235 + await svc.delete_shuffle_integration(integration_id, customer_code, session)
236 + return {"success": True, "message": "Integration deleted"}
237 +
238 +
239 +@notifications_router.get(
240 + "/customers/{customer_code}/shuffle_integrations/{integration_id}/apps",
241 + response_model=ShuffleAppListResponse,
242 + description=(
243 + "Fetch the Shuffle app catalog scoped to this customer's org. Used "
244 + "by the route form's app picker so admins can pick from a list "
245 + "instead of hand-typing UUIDs."
246 + ),
247 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
248 +)
249 +async def list_shuffle_apps_route(
250 + customer_code: str,
251 + integration_id: int,
252 + session: AsyncSession = Depends(get_db),
253 +) -> ShuffleAppListResponse:
254 + apps = await svc.list_apps_for_integration(integration_id, customer_code, session)
255 + return ShuffleAppListResponse(
256 + success=True,
257 + message=f"{len(apps)} app(s) retrieved",
258 + apps=apps,
259 + )
260 +
261 +
262 +@notifications_router.get(
263 + "/customers/{customer_code}/shuffle_integrations/{integration_id}/verify",
264 + response_model=ShuffleVerifyResponse,
265 + description="Probe Shuffle with the integration's Org-Id to confirm the connector is reachable and the org is valid.",
266 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
267 +)
268 +async def verify_shuffle_integration_route(
269 + customer_code: str,
270 + integration_id: int,
271 + session: AsyncSession = Depends(get_db),
272 +) -> ShuffleVerifyResponse:
273 + result = await svc.verify_integration(integration_id, customer_code, session)
274 + return ShuffleVerifyResponse(**result)
275 +
276 +
277 +# ---------------------------------------------------------------------------
278 +# Dispatch — called by Talon after each investigation
279 +# ---------------------------------------------------------------------------
280 +
281 +
282 +@notifications_router.post(
283 + "/notifications/dispatch",
284 + response_model=DispatchResponse,
285 + description=(
286 + "Walk the customer's notification routes for the given trigger and "
287 + "severity, dispatch each match, and log each outcome. Idempotent — "
288 + "re-dispatching the same (customer, alert, route, trigger) is a no-op. "
289 + "Talon calls this after writing back an investigation report."
290 + ),
291 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
292 +)
293 +async def dispatch_route(
294 + payload: DispatchRequest,
295 + session: AsyncSession = Depends(get_db),
296 +) -> DispatchResponse:
297 + logger.info(
298 + f"Notification dispatch requested for customer {payload.customer_code} "
299 + f"alert {payload.alert_id} trigger {payload.trigger.value} "
300 + f"severity {payload.severity_assessment.value}",
301 + )
302 + return await svc.dispatch(payload, session)
backend/app/notifications/schema/__init__.py
backend/app/notifications/schema/notifications.py new
+351
@@ -0,0 +1,351 @@
1 +"""
2 +Pydantic schemas for the notification routing module.
3 +
4 +The wire-level enums (NotificationTrigger, NotificationChannel,
5 +NotificationSeverity) mirror the v1 string set the database column
6 +accepts. The DB columns themselves are plain strings so adding a new
7 +trigger or channel later is a data-only change — these enums exist
8 +purely for input validation at the API boundary.
9 +"""
10 +
11 +from __future__ import annotations
12 +
13 +from datetime import datetime
14 +from enum import Enum
15 +from typing import List
16 +from typing import Optional
17 +
18 +from pydantic import BaseModel
19 +from pydantic import Field
20 +from pydantic import validator
21 +
22 +# ---------------------------------------------------------------------------
23 +# Enums (input validation only — DB stores strings)
24 +# ---------------------------------------------------------------------------
25 +
26 +
27 +class NotificationTrigger(str, Enum):
28 + """When a route should fire.
29 +
30 + `INVESTIGATION_COMPLETE` covers "tell me whenever the AI finishes,
31 + regardless of verdict" — useful for low-volume customers who want a
32 + receipt for every run. `SEVERITY_CRITICAL_OR_HIGH` is the noisier
33 + feed gated by severity for SOC teams that only want to be paged on
34 + real findings. More triggers can be added later (true-positive after
35 + review, IOC accuracy thresholds, etc.) without a schema change.
36 + """
37 +
38 + INVESTIGATION_COMPLETE = "investigation_complete"
39 + SEVERITY_CRITICAL_OR_HIGH = "severity_critical_or_high"
40 +
41 +
42 +class NotificationChannel(str, Enum):
43 + """Delivery channel set.
44 +
45 + `smtp_email` is direct SMTP via env config (CoPilot deployment-wide).
46 + `shuffle` proxies to Shuffle's hosted MCP — each customer points at
47 + their own Shuffle Org via `customer_shuffle_integration`, and Shuffle
48 + handles the OAuth-authenticated downstream app (Slack workspace,
49 + Outlook tenant, Teams, etc.). Routes referencing `shuffle` MUST
50 + populate the `shuffle_integration_id` + `shuffle_app_id` columns.
51 + """
52 +
53 + SMTP_EMAIL = "smtp_email"
54 + SHUFFLE = "shuffle"
55 +
56 +
57 +class NotificationSeverity(str, Enum):
58 + """Severity tiers, ordered. Mirrors AiAnalystReport.severity_assessment.
59 +
60 + The dispatch service treats `min_severity` inclusively — a route
61 + with `min_severity="High"` fires on Critical and High but not Medium.
62 + """
63 +
64 + CRITICAL = "Critical"
65 + HIGH = "High"
66 + MEDIUM = "Medium"
67 + LOW = "Low"
68 + INFORMATIONAL = "Informational"
69 +
70 +
71 +class DispatchStatus(str, Enum):
72 + """Result classes for notification_dispatch_log.status."""
73 +
74 + SENT = "sent"
75 + FAILED = "failed"
76 + SKIPPED = "skipped"
77 +
78 +
79 +# Severity ordering for `min_severity` filtering. Index = priority,
80 +# higher = more severe. Used by the dispatch service to gate routes.
81 +SEVERITY_ORDER: List[str] = [
82 + NotificationSeverity.INFORMATIONAL.value,
83 + NotificationSeverity.LOW.value,
84 + NotificationSeverity.MEDIUM.value,
85 + NotificationSeverity.HIGH.value,
86 + NotificationSeverity.CRITICAL.value,
87 +]
88 +
89 +
90 +# ---------------------------------------------------------------------------
91 +# Routes — request/response shapes
92 +# ---------------------------------------------------------------------------
93 +
94 +
95 +class NotificationRouteBase(BaseModel):
96 + name: str = Field(..., min_length=1, max_length=128, description="Human label for the rule (e.g. 'SOC team Slack #alerts').")
97 + trigger: NotificationTrigger
98 + channel: NotificationChannel
99 + # For SMTP: comma-separated recipient emails. For Shuffle: free-form
100 + # destination hint (e.g. '#soc-alerts', 'ir@corp.com') that gets
101 + # injected into Shuffle's natural-language input — Shuffle's app
102 + # agent figures out how to route it within the authenticated app.
103 + destination: str = Field(
104 + ...,
105 + min_length=1,
106 + description="SMTP recipient email(s) or Shuffle destination hint (channel name / address / handle).",
107 + )
108 + min_severity: NotificationSeverity = NotificationSeverity.MEDIUM
109 + format_template: Optional[str] = Field(
110 + default=None,
111 + description="Optional Jinja override for the message body. Leave empty to use the channel default.",
112 + )
113 + enabled: bool = True
114 +
115 + # Phase 2: Shuffle routing target. Required when channel='shuffle'.
116 + # The integration row scopes the dispatch to a specific customer
117 + # Shuffle org; the app id + name describe which app within that
118 + # org receives the natural-language input.
119 + shuffle_integration_id: Optional[int] = Field(
120 + default=None,
121 + description="ID of the customer_shuffle_integration row (required when channel='shuffle').",
122 + )
123 + shuffle_app_id: Optional[str] = Field(default=None, description="Shuffle app UUID (required when channel='shuffle').")
124 + shuffle_app_name: Optional[str] = Field(
125 + default=None,
126 + description="Human-readable Shuffle app name cached for the UI list (e.g. 'Slack').",
127 + )
128 +
129 + @validator("destination")
130 + def _strip_destination(cls, v: str) -> str:
131 + return v.strip()
132 +
133 + @validator("shuffle_integration_id", always=True)
134 + def _shuffle_integration_required(cls, v, values):
135 + if values.get("channel") == NotificationChannel.SHUFFLE and not v:
136 + raise ValueError("shuffle_integration_id is required when channel='shuffle'")
137 + return v
138 +
139 + @validator("shuffle_app_id", always=True)
140 + def _shuffle_app_required(cls, v, values):
141 + if values.get("channel") == NotificationChannel.SHUFFLE and not v:
142 + raise ValueError("shuffle_app_id is required when channel='shuffle'")
143 + return v
144 +
145 +
146 +class NotificationRouteCreate(NotificationRouteBase):
147 + """Body for POST /customers/{code}/notification_routes."""
148 +
149 +
150 +class NotificationRouteUpdate(BaseModel):
151 + """Body for PATCH — every field optional. Mirrors the editable subset
152 + of NotificationRouteBase."""
153 +
154 + name: Optional[str] = Field(default=None, min_length=1, max_length=128)
155 + trigger: Optional[NotificationTrigger] = None
156 + channel: Optional[NotificationChannel] = None
157 + destination: Optional[str] = Field(default=None, min_length=1)
158 + min_severity: Optional[NotificationSeverity] = None
159 + format_template: Optional[str] = None
160 + enabled: Optional[bool] = None
161 + # Shuffle target — included on PATCH so admins can re-point a route
162 + # at a different integration / app without recreating it.
163 + shuffle_integration_id: Optional[int] = None
164 + shuffle_app_id: Optional[str] = None
165 + shuffle_app_name: Optional[str] = None
166 +
167 +
168 +class NotificationRouteRead(NotificationRouteBase):
169 + id: int
170 + customer_code: str
171 + last_dispatched_at: Optional[datetime] = None
172 + dispatch_count: int = 0
173 + created_by: Optional[str] = None
174 + created_at: datetime
175 + updated_at: Optional[datetime] = None
176 +
177 + class Config:
178 + orm_mode = True
179 +
180 +
181 +# ---------------------------------------------------------------------------
182 +# Shuffle integrations (Phase 2)
183 +# ---------------------------------------------------------------------------
184 +
185 +
186 +class ShuffleIntegrationBase(BaseModel):
187 + display_name: str = Field(..., min_length=1, max_length=128, description="Human label, e.g. 'Acme Production Shuffle'.")
188 + shuffle_org_id: str = Field(
189 + ...,
190 + min_length=1,
191 + max_length=64,
192 + description="The customer's Shuffle Org-Id. Sent as the Org-Id header on each dispatch.",
193 + )
194 + enabled: bool = True
195 +
196 + @validator("shuffle_org_id")
197 + def _strip_org(cls, v: str) -> str:
198 + return v.strip()
199 +
200 +
201 +class ShuffleIntegrationCreate(ShuffleIntegrationBase):
202 + """Body for POST /customers/{code}/shuffle_integrations."""
203 +
204 +
205 +class ShuffleIntegrationUpdate(BaseModel):
206 + """Body for PATCH — every field optional."""
207 +
208 + display_name: Optional[str] = Field(default=None, min_length=1, max_length=128)
209 + shuffle_org_id: Optional[str] = Field(default=None, min_length=1, max_length=64)
210 + enabled: Optional[bool] = None
211 +
212 +
213 +class ShuffleIntegrationRead(ShuffleIntegrationBase):
214 + id: int
215 + customer_code: str
216 + last_used_at: Optional[datetime] = None
217 + created_by: Optional[str] = None
218 + created_at: datetime
219 + updated_at: Optional[datetime] = None
220 +
221 + class Config:
222 + orm_mode = True
223 +
224 +
225 +class ShuffleIntegrationListResponse(BaseModel):
226 + success: bool = True
227 + message: str = "Integrations retrieved"
228 + integrations: List[ShuffleIntegrationRead]
229 +
230 +
231 +class ShuffleIntegrationResponse(BaseModel):
232 + success: bool = True
233 + message: str = "Integration saved"
234 + integration: ShuffleIntegrationRead
235 +
236 +
237 +class ShuffleApp(BaseModel):
238 + """One Shuffle app in the catalog the customer's org has access to.
239 +
240 + Used to populate the route form's app picker. We forward the minimal
241 + subset Shuffle returns — enough for the UI to render a recognizable
242 + list and for the form to record the (id, name) pair on submit.
243 + """
244 +
245 + id: str
246 + name: str
247 + description: Optional[str] = None
248 + large_image: Optional[str] = None
249 +
250 +
251 +class ShuffleAppListResponse(BaseModel):
252 + success: bool = True
253 + message: str = "Apps retrieved"
254 + apps: List[ShuffleApp]
255 +
256 +
257 +class ShuffleVerifyResponse(BaseModel):
258 + success: bool = True
259 + message: str
260 + org_id: str
261 + app_count: Optional[int] = None
262 + error: Optional[str] = None
263 +
264 +
265 +class NotificationRouteListResponse(BaseModel):
266 + success: bool = True
267 + message: str = "Routes retrieved"
268 + routes: List[NotificationRouteRead]
269 +
270 +
271 +class NotificationRouteResponse(BaseModel):
272 + success: bool = True
273 + message: str = "Route saved"
274 + route: NotificationRouteRead
275 +
276 +
277 +# ---------------------------------------------------------------------------
278 +# Dispatch log — read-only audit shapes
279 +# ---------------------------------------------------------------------------
280 +
281 +
282 +class DispatchLogRead(BaseModel):
283 + id: int
284 + customer_code: str
285 + alert_id: int
286 + route_id: int
287 + trigger: str
288 + dispatched_at: datetime
289 + status: DispatchStatus
290 + error_message: Optional[str] = None
291 + latency_ms: Optional[int] = None
292 + payload_preview: Optional[str] = None
293 + shuffle_execution_id: Optional[str] = None
294 +
295 + class Config:
296 + orm_mode = True
297 +
298 +
299 +class DispatchLogListResponse(BaseModel):
300 + success: bool = True
301 + message: str = "Dispatch log retrieved"
302 + entries: List[DispatchLogRead]
303 +
304 +
305 +# ---------------------------------------------------------------------------
306 +# Dispatch endpoint — the one Talon calls
307 +# ---------------------------------------------------------------------------
308 +
309 +
310 +class DispatchRequest(BaseModel):
311 + """Body for POST /notifications/dispatch — what Talon sends after
312 + completing an investigation. Carries the minimum the dispatch
313 + service needs to (a) decide which routes match and (b) format the
314 + message body."""
315 +
316 + customer_code: str = Field(..., description="The alert's customer_code — scopes the route lookup.")
317 + alert_id: int = Field(..., description="The alert this investigation was for. Used as the idempotency key.")
318 + trigger: NotificationTrigger = Field(
319 + ...,
320 + description="Which trigger Talon thinks applies. The service still re-validates it against the alert's severity.",
321 + )
322 + severity_assessment: NotificationSeverity = Field(
323 + ...,
324 + description="The report's assessed severity — used for `min_severity` filtering.",
325 + )
326 + summary: str = Field(..., description="One-paragraph human-readable summary. Renders into the default template.")
327 + report_url: Optional[str] = Field(default=None, description="Deep link back to the full report in CoPilot.")
328 + alert_name: Optional[str] = Field(default=None, description="Original alert title for context in the message.")
329 +
330 +
331 +class DispatchOutcome(BaseModel):
332 + route_id: int
333 + route_name: str
334 + channel: str
335 + status: DispatchStatus
336 + error_message: Optional[str] = None
337 + latency_ms: Optional[int] = None
338 + # Shuffle's POST /apps/{id}/mcp returns this on a successful kickoff.
339 + # Surfaced in the response so the calling agent (Talon) can include
340 + # it in its analyst summary if the dispatch went through Shuffle.
341 + shuffle_execution_id: Optional[str] = None
342 +
343 +
344 +class DispatchResponse(BaseModel):
345 + success: bool = True
346 + message: str = "Dispatch complete"
347 + routes_matched: int
348 + dispatched: int
349 + skipped: int
350 + failed: int
351 + outcomes: List[DispatchOutcome]
backend/app/notifications/services/__init__.py
backend/app/notifications/services/dispatchers.py new
+280
@@ -0,0 +1,280 @@
1 +"""
2 +Channel-specific delivery helpers for the notification dispatcher.
3 +
4 +Each helper is async, returns a (status, error_message, latency_ms,
5 +*provider-specific extras) tuple, and never raises — failures are
6 +reported via the tuple so the caller can log them in a single shape
7 +regardless of which channel failed. This keeps the dispatch loop's
8 +try/except surface trivial.
9 +
10 +Channels:
11 + - smtp_email : direct SMTP via env config (deployment-wide)
12 + - shuffle : POST to https://shuffler.io/api/v1/apps/{id}/mcp using
13 + the deployment's Bearer (from the Shuffle connector)
14 + + customer's Org-Id header. Fire-and-record — we
15 + capture Shuffle's execution_id but don't poll for the
16 + downstream provider's terminal state.
17 +"""
18 +
19 +from __future__ import annotations
20 +
21 +import asyncio
22 +import os
23 +import smtplib
24 +import ssl
25 +import time
26 +from email.message import EmailMessage
27 +from typing import Any
28 +from typing import Dict
29 +from typing import Optional
30 +from typing import Tuple
31 +
32 +import httpx
33 +from loguru import logger
34 +
35 +# Tuple shape used by every dispatcher: (status, error_message, latency_ms)
36 +# status is one of 'sent' | 'failed'; the caller turns 'sent' into a
37 +# DispatchStatus.SENT row. error_message is None on success.
38 +DispatchResult = Tuple[str, Optional[str], int]
39 +
40 +
41 +# Shuffle's dispatcher returns a 4-tuple — the standard triple plus an
42 +# execution_id. None when the kickoff failed before Shuffle returned one.
43 +ShuffleDispatchResult = Tuple[str, Optional[str], int, Optional[str]]
44 +
45 +
46 +# Hard cap on the upstream provider call. SMTP usually completes in
47 +# <2s; Shuffle's MCP kickoff is usually <1s but cold-start paths
48 +# (first call against an org, or Shuffle's backend warming) can spike.
49 +# Two budgets so a slow Shuffle response doesn't masquerade as a
50 +# dispatch failure while keeping SMTP failures fast.
51 +_SMTP_TIMEOUT_S = 10.0
52 +_SHUFFLE_TIMEOUT_S = 30.0
53 +
54 +
55 +async def dispatch_smtp_email(
56 + recipients: list[str],
57 + subject: str,
58 + body: str,
59 +) -> DispatchResult:
60 + """Send a plaintext email to one or more recipients via SMTP.
61 +
62 + Configuration is read from environment variables on each call so a
63 + customer can re-point SMTP at runtime without restarting CoPilot:
64 +
65 + SMTP_HOST hostname (required)
66 + SMTP_PORT int, defaults to 587 (STARTTLS)
67 + SMTP_USER optional — when set, AUTH LOGIN is performed
68 + SMTP_PASSWORD optional — paired with SMTP_USER
69 + SMTP_FROM From: header value (required)
70 + SMTP_USE_TLS 'true' (default) | 'false' — STARTTLS toggle
71 + """
72 + started = time.monotonic()
73 + try:
74 + host = os.getenv("SMTP_HOST")
75 + from_addr = os.getenv("SMTP_FROM")
76 + if not host or not from_addr:
77 + latency_ms = int((time.monotonic() - started) * 1000)
78 + return (
79 + "failed",
80 + "SMTP not configured (set SMTP_HOST and SMTP_FROM)",
81 + latency_ms,
82 + )
83 +
84 + port = int(os.getenv("SMTP_PORT", "587"))
85 + user = os.getenv("SMTP_USER")
86 + password = os.getenv("SMTP_PASSWORD")
87 + use_tls = os.getenv("SMTP_USE_TLS", "true").lower() != "false"
88 +
89 + msg = EmailMessage()
90 + msg["Subject"] = subject
91 + msg["From"] = from_addr
92 + msg["To"] = ", ".join(recipients)
93 + msg.set_content(body)
94 +
95 + # smtplib is sync — push it to a thread so the event loop stays
96 + # responsive while we wait on the network.
97 + await asyncio.get_running_loop().run_in_executor(
98 + None,
99 + _send_smtp_sync,
100 + host,
101 + port,
102 + user,
103 + password,
104 + use_tls,
105 + msg,
106 + )
107 + latency_ms = int((time.monotonic() - started) * 1000)
108 + return ("sent", None, latency_ms)
109 + except Exception as e: # noqa: BLE001
110 + latency_ms = int((time.monotonic() - started) * 1000)
111 + logger.warning(f"SMTP email dispatch failed: {e!r}")
112 + return ("failed", f"{type(e).__name__}: {e}", latency_ms)
113 +
114 +
115 +def _send_smtp_sync(
116 + host: str,
117 + port: int,
118 + user: str | None,
119 + password: str | None,
120 + use_tls: bool,
121 + msg: EmailMessage,
122 +) -> None:
123 + """Synchronous SMTP send — invoked in a worker thread by the async
124 + wrapper. Raises on failure; the caller catches and reports."""
125 + with smtplib.SMTP(host, port, timeout=_SMTP_TIMEOUT_S) as smtp:
126 + smtp.ehlo()
127 + if use_tls:
128 + ctx = ssl.create_default_context()
129 + smtp.starttls(context=ctx)
130 + smtp.ehlo()
131 + if user and password:
132 + smtp.login(user, password)
133 + smtp.send_message(msg)
134 +
135 +
136 +# ---------------------------------------------------------------------------
137 +# Shuffle dispatcher (Phase 2)
138 +# ---------------------------------------------------------------------------
139 +
140 +
141 +def _shuffle_headers(api_key: str, org_id: str) -> Dict[str, str]:
142 + """Bearer auth + Org-Id scope.
143 +
144 + The deployment's Shuffle API key (admin-scoped, lives in CoPilot's
145 + Shuffle connector row) plus the customer's per-integration Org-Id
146 + is what scopes a dispatch to the right org. The MCP server in
147 + shuffle-mcp-server uses the same pair.
148 + """
149 + return {
150 + "Authorization": f"Bearer {api_key}",
151 + "Org-Id": org_id,
152 + "Content-Type": "application/json",
153 + "Accept": "application/json",
154 + }
155 +
156 +
157 +async def dispatch_shuffle(
158 + *,
159 + base_url: str,
160 + api_key: str,
161 + org_id: str,
162 + app_id: str,
163 + input_text: str,
164 + environment: str = "Shuffle",
165 +) -> ShuffleDispatchResult:
166 + """Kick off a Shuffle AI Agent run for one app.
167 +
168 + Wraps `POST {base_url}/api/v1/apps/{app_id}/mcp`. Shuffle's response
169 + contains an `execution_id` + `authorization` — the actual downstream
170 + delivery (Slack message, email send, etc.) happens asynchronously
171 + inside Shuffle. Phase 2 is fire-and-record: we treat HTTP 200 from
172 + Shuffle as `sent` and stash the execution_id for forensic lookups,
173 + but we do NOT poll for terminal state. Phase 4 may add an optional
174 + poll-with-timeout mode for high-criticality routes.
175 +
176 + Returns (status, error_message, latency_ms, execution_id_or_None).
177 + """
178 + url = f"{base_url.rstrip('/')}/api/v1/apps/{app_id}/mcp"
179 + body: Dict[str, Any] = {
180 + "jsonrpc": "2.0",
181 + "id": "1",
182 + "method": "tools/call",
183 + "params": {
184 + "tool_id": app_id,
185 + "tool_name": app_id,
186 + "input": {"text": input_text},
187 + "environment": environment,
188 + },
189 + }
190 + started = time.monotonic()
191 + try:
192 + async with httpx.AsyncClient(timeout=_SHUFFLE_TIMEOUT_S, http2=True) as client:
193 + response = await client.post(url, headers=_shuffle_headers(api_key, org_id), json=body)
194 + latency_ms = int((time.monotonic() - started) * 1000)
195 +
196 + if response.status_code in (401, 403):
197 + return (
198 + "failed",
199 + f"Shuffle authentication failed ({response.status_code}): {response.text[:200]}",
200 + latency_ms,
201 + None,
202 + )
203 + if response.status_code >= 400:
204 + return (
205 + "failed",
206 + f"Shuffle returned {response.status_code}: {response.text[:200]}",
207 + latency_ms,
208 + None,
209 + )
210 +
211 + try:
212 + data = response.json()
213 + except ValueError:
214 + return ("failed", "Shuffle returned non-JSON response", latency_ms, None)
215 +
216 + # Shuffle's success body shape: {success, execution_id, authorization, mode}.
217 + # `execution_id` is what we stash for forensic correlation in
218 + # the dispatch log; no polling.
219 + execution_id = data.get("execution_id") if isinstance(data, dict) else None
220 + if isinstance(data, dict) and data.get("success") is False:
221 + return (
222 + "failed",
223 + f"Shuffle reported failure: {data.get('reason') or data.get('error') or data}",
224 + latency_ms,
225 + execution_id,
226 + )
227 + return ("sent", None, latency_ms, execution_id)
228 + except Exception as e: # noqa: BLE001
229 + latency_ms = int((time.monotonic() - started) * 1000)
230 + logger.warning(f"Shuffle dispatch failed: {e!r}")
231 + return ("failed", f"{type(e).__name__}: {e}", latency_ms, None)
232 +
233 +
234 +async def list_shuffle_apps(
235 + *,
236 + base_url: str,
237 + api_key: str,
238 + org_id: str,
239 +) -> Tuple[bool, list, Optional[str]]:
240 + """Fetch the apps catalog the customer's Shuffle org has access to.
241 +
242 + Used by the route form's app picker so admins can pick from a list
243 + instead of hand-typing UUIDs. Returns (ok, apps, error_message).
244 + """
245 + url = f"{base_url.rstrip('/')}/api/v1/apps"
246 + try:
247 + async with httpx.AsyncClient(timeout=_SHUFFLE_TIMEOUT_S, http2=True) as client:
248 + response = await client.get(url, headers=_shuffle_headers(api_key, org_id))
249 + if response.status_code in (401, 403):
250 + return (False, [], f"Shuffle authentication failed ({response.status_code})")
251 + if response.status_code >= 400:
252 + return (False, [], f"Shuffle returned {response.status_code}: {response.text[:200]}")
253 + try:
254 + data = response.json()
255 + except ValueError:
256 + return (False, [], "Shuffle returned non-JSON response")
257 +
258 + # Shuffle returns the catalog as a list of app objects with at
259 + # minimum {id, name, description}. We forward the minimal shape
260 + # the UI needs and let the route form record (id, name) on submit.
261 + if not isinstance(data, list):
262 + return (False, [], f"Unexpected Shuffle response shape: {type(data).__name__}")
263 + return (True, data, None)
264 + except Exception as e: # noqa: BLE001
265 + logger.warning(f"Shuffle apps list failed: {e!r}")
266 + return (False, [], f"{type(e).__name__}: {e}")
267 +
268 +
269 +async def verify_shuffle_org(
270 + *,
271 + base_url: str,
272 + api_key: str,
273 + org_id: str,
274 +) -> Tuple[bool, Optional[int], Optional[str]]:
275 + """Quick auth probe for an integration. Used by the 'Test connection'
276 + button in the integration form. Returns (ok, app_count, error)."""
277 + ok, apps, error = await list_shuffle_apps(base_url=base_url, api_key=api_key, org_id=org_id)
278 + if not ok:
279 + return (False, None, error)
280 + return (True, len(apps), None)
backend/app/notifications/services/notifications.py new
+773
@@ -0,0 +1,773 @@
1 +"""
2 +Notification routing service — CRUD for routes, the dispatch loop, and
3 +a read-only view over the dispatch log.
4 +
5 +The dispatch loop is the heart of the module. It's called via
6 +`POST /notifications/dispatch` (Talon's after-investigation hook) and
7 +walks every enabled route for the customer, filters by trigger and
8 +severity, formats the message body per channel, calls the appropriate
9 +dispatcher, and records the outcome in `notification_dispatch_log`. The
10 +log row is what gives us idempotency — re-dispatching the same
11 +(customer, alert, route, trigger) is a no-op.
12 +"""
13 +
14 +from __future__ import annotations
15 +
16 +from datetime import datetime
17 +from typing import List
18 +from typing import Optional
19 +
20 +from fastapi import HTTPException
21 +from loguru import logger
22 +from sqlalchemy import desc
23 +from sqlalchemy import select
24 +from sqlalchemy import update
25 +from sqlalchemy.exc import IntegrityError
26 +from sqlalchemy.ext.asyncio import AsyncSession
27 +
28 +from app.connectors.utils import get_connector_info_from_db
29 +from app.db.universal_models import CustomerNotificationRoute
30 +from app.db.universal_models import CustomerShuffleIntegration
31 +from app.db.universal_models import NotificationDispatchLog
32 +from app.notifications.schema.notifications import SEVERITY_ORDER
33 +from app.notifications.schema.notifications import DispatchOutcome
34 +from app.notifications.schema.notifications import DispatchRequest
35 +from app.notifications.schema.notifications import DispatchResponse
36 +from app.notifications.schema.notifications import DispatchStatus
37 +from app.notifications.schema.notifications import NotificationChannel
38 +from app.notifications.schema.notifications import NotificationRouteCreate
39 +from app.notifications.schema.notifications import NotificationRouteUpdate
40 +from app.notifications.schema.notifications import NotificationTrigger
41 +from app.notifications.schema.notifications import ShuffleApp
42 +from app.notifications.schema.notifications import ShuffleIntegrationCreate
43 +from app.notifications.schema.notifications import ShuffleIntegrationUpdate
44 +from app.notifications.services.dispatchers import dispatch_shuffle
45 +from app.notifications.services.dispatchers import dispatch_smtp_email
46 +from app.notifications.services.dispatchers import (
47 + list_shuffle_apps as shuffle_apps_client,
48 +)
49 +from app.notifications.services.dispatchers import (
50 + verify_shuffle_org as verify_shuffle_org_client,
51 +)
52 +
53 +# Name of the Shuffle row in CoPilot's connectors table. The
54 +# `connector_url` (Shuffle base URL) and `connector_api_key` (admin
55 +# Bearer token) are read fresh on every dispatch so a key rotation
56 +# takes effect without restarting the backend.
57 +_SHUFFLE_CONNECTOR_NAME = "Shuffle"
58 +
59 +
60 +async def _get_shuffle_connector(session: AsyncSession) -> tuple[str, str]:
61 + """Fetch (base_url, api_key) for the Shuffle connector. Raises
62 + HTTPException if the connector row is missing or unconfigured —
63 + surfaces a clear 4xx in the dispatch endpoint instead of a generic
64 + 500 when an admin forgets to configure Shuffle."""
65 + info = await get_connector_info_from_db(_SHUFFLE_CONNECTOR_NAME, session)
66 + if not info:
67 + raise HTTPException(
68 + status_code=503,
69 + detail=(
70 + "Shuffle connector is not configured in CoPilot. "
71 + "Add the Shuffle connector with a valid API key before "
72 + "creating Shuffle-channel notification routes."
73 + ),
74 + )
75 + api_key = info.get("connector_api_key") or ""
76 + base_url = info.get("connector_url") or "https://shuffler.io"
77 + if not api_key:
78 + raise HTTPException(
79 + status_code=503,
80 + detail="Shuffle connector is configured but has no API key set.",
81 + )
82 + return (base_url, api_key)
83 +
84 +
85 +# ---------------------------------------------------------------------------
86 +# CRUD
87 +# ---------------------------------------------------------------------------
88 +
89 +
90 +async def list_routes(customer_code: str, session: AsyncSession) -> List[CustomerNotificationRoute]:
91 + """All routes for a customer, newest-first. UI list source."""
92 + result = await session.execute(
93 + select(CustomerNotificationRoute)
94 + .where(CustomerNotificationRoute.customer_code == customer_code)
95 + .order_by(desc(CustomerNotificationRoute.created_at)),
96 + )
97 + return result.scalars().all()
98 +
99 +
100 +async def get_route(route_id: int, customer_code: str, session: AsyncSession) -> CustomerNotificationRoute:
101 + """Single route, scoped by customer to keep the tenant boundary
102 + explicit at lookup time."""
103 + result = await session.execute(
104 + select(CustomerNotificationRoute).where(
105 + CustomerNotificationRoute.id == route_id,
106 + CustomerNotificationRoute.customer_code == customer_code,
107 + ),
108 + )
109 + route = result.scalars().first()
110 + if not route:
111 + raise HTTPException(status_code=404, detail="Route not found")
112 + return route
113 +
114 +
115 +async def create_route(
116 + customer_code: str,
117 + payload: NotificationRouteCreate,
118 + created_by: Optional[str],
119 + session: AsyncSession,
120 +) -> CustomerNotificationRoute:
121 + # Shuffle-channel sanity check: the integration must exist AND
122 + # belong to the same customer. Pydantic validators caught the "is
123 + # the field present" question; this catches the cross-tenant version.
124 + if payload.channel == NotificationChannel.SHUFFLE:
125 + await _ensure_integration_belongs_to_customer(payload.shuffle_integration_id, customer_code, session)
126 +
127 + route = CustomerNotificationRoute(
128 + customer_code=customer_code,
129 + name=payload.name,
130 + trigger=payload.trigger.value,
131 + channel=payload.channel.value,
132 + destination=payload.destination,
133 + min_severity=payload.min_severity.value,
134 + format_template=payload.format_template,
135 + enabled=payload.enabled,
136 + created_by=created_by,
137 + shuffle_integration_id=payload.shuffle_integration_id,
138 + shuffle_app_id=payload.shuffle_app_id,
139 + shuffle_app_name=payload.shuffle_app_name,
140 + )
141 + session.add(route)
142 + await session.commit()
143 + await session.refresh(route)
144 + return route
145 +
146 +
147 +async def update_route(
148 + route_id: int,
149 + customer_code: str,
150 + payload: NotificationRouteUpdate,
151 + session: AsyncSession,
152 +) -> CustomerNotificationRoute:
153 + route = await get_route(route_id, customer_code, session)
154 +
155 + # Pydantic v1 vs v2 parity — exclude_unset returns only the fields
156 + # the client actually sent so a PATCH that omits `enabled` doesn't
157 + # accidentally re-flag it.
158 + data = payload.dict(exclude_unset=True)
159 +
160 + # If the PATCH switches the channel to Shuffle (or re-points an
161 + # existing Shuffle route at a different integration), the new
162 + # integration must belong to the same customer.
163 + new_integration_id = data.get("shuffle_integration_id", route.shuffle_integration_id)
164 + new_channel = data.get("channel")
165 + if hasattr(new_channel, "value"):
166 + new_channel_value = new_channel.value
167 + else:
168 + new_channel_value = new_channel or route.channel
169 + if new_channel_value == NotificationChannel.SHUFFLE.value and new_integration_id:
170 + await _ensure_integration_belongs_to_customer(new_integration_id, customer_code, session)
171 +
172 + for field, value in data.items():
173 + # Enums: write the underlying string into the DB column.
174 + if hasattr(value, "value"):
175 + value = value.value
176 + setattr(route, field, value)
177 + route.updated_at = datetime.utcnow()
178 +
179 + await session.commit()
180 + await session.refresh(route)
181 + return route
182 +
183 +
184 +async def delete_route(route_id: int, customer_code: str, session: AsyncSession) -> None:
185 + route = await get_route(route_id, customer_code, session)
186 + await session.delete(route)
187 + await session.commit()
188 +
189 +
190 +# ---------------------------------------------------------------------------
191 +# Shuffle integrations (Phase 2)
192 +# ---------------------------------------------------------------------------
193 +
194 +
195 +async def _ensure_integration_belongs_to_customer(
196 + integration_id: int,
197 + customer_code: str,
198 + session: AsyncSession,
199 +) -> CustomerShuffleIntegration:
200 + """Tenant-boundary check for Shuffle integration references.
201 +
202 + Used at route create/update time. Without this, a malicious or
203 + typo'd `shuffle_integration_id` could silently route customer A's
204 + notifications through customer B's Shuffle org. Failing closed with
205 + a 400 is the right answer — the route never persists.
206 + """
207 + result = await session.execute(
208 + select(CustomerShuffleIntegration).where(
209 + CustomerShuffleIntegration.id == integration_id,
210 + CustomerShuffleIntegration.customer_code == customer_code,
211 + ),
212 + )
213 + integration = result.scalars().first()
214 + if not integration:
215 + raise HTTPException(
216 + status_code=400,
217 + detail=(
218 + f"Shuffle integration {integration_id} not found for "
219 + f"customer {customer_code}. Cross-tenant references are "
220 + f"refused — create the integration on the target customer first."
221 + ),
222 + )
223 + return integration
224 +
225 +
226 +async def list_shuffle_integrations(customer_code: str, session: AsyncSession) -> List[CustomerShuffleIntegration]:
227 + result = await session.execute(
228 + select(CustomerShuffleIntegration)
229 + .where(CustomerShuffleIntegration.customer_code == customer_code)
230 + .order_by(desc(CustomerShuffleIntegration.created_at)),
231 + )
232 + return result.scalars().all()
233 +
234 +
235 +async def get_shuffle_integration(integration_id: int, customer_code: str, session: AsyncSession) -> CustomerShuffleIntegration:
236 + return await _ensure_integration_belongs_to_customer(integration_id, customer_code, session)
237 +
238 +
239 +async def create_shuffle_integration(
240 + customer_code: str,
241 + payload: ShuffleIntegrationCreate,
242 + created_by: Optional[str],
243 + session: AsyncSession,
244 +) -> CustomerShuffleIntegration:
245 + integration = CustomerShuffleIntegration(
246 + customer_code=customer_code,
247 + display_name=payload.display_name,
248 + shuffle_org_id=payload.shuffle_org_id,
249 + enabled=payload.enabled,
250 + created_by=created_by,
251 + )
252 + session.add(integration)
253 + await session.commit()
254 + await session.refresh(integration)
255 + return integration
256 +
257 +
258 +async def update_shuffle_integration(
259 + integration_id: int,
260 + customer_code: str,
261 + payload: ShuffleIntegrationUpdate,
262 + session: AsyncSession,
263 +) -> CustomerShuffleIntegration:
264 + integration = await _ensure_integration_belongs_to_customer(integration_id, customer_code, session)
265 + data = payload.dict(exclude_unset=True)
266 + for field, value in data.items():
267 + setattr(integration, field, value)
268 + integration.updated_at = datetime.utcnow()
269 + await session.commit()
270 + await session.refresh(integration)
271 + return integration
272 +
273 +
274 +async def delete_shuffle_integration(integration_id: int, customer_code: str, session: AsyncSession) -> None:
275 + integration = await _ensure_integration_belongs_to_customer(integration_id, customer_code, session)
276 + # Refuse if any routes still reference this integration — better to
277 + # surface the dependency than silently leave routes pointing at a
278 + # missing FK that the dispatch loop will then have to skip.
279 + result = await session.execute(
280 + select(CustomerNotificationRoute).where(CustomerNotificationRoute.shuffle_integration_id == integration_id),
281 + )
282 + referencing = result.scalars().all()
283 + if referencing:
284 + names = ", ".join(r.name for r in referencing[:5])
285 + raise HTTPException(
286 + status_code=409,
287 + detail=(
288 + f"Integration is referenced by {len(referencing)} route(s) "
289 + f"({names}{'…' if len(referencing) > 5 else ''}). Delete "
290 + f"or re-point those routes first."
291 + ),
292 + )
293 + await session.delete(integration)
294 + await session.commit()
295 +
296 +
297 +async def list_apps_for_integration(
298 + integration_id: int,
299 + customer_code: str,
300 + session: AsyncSession,
301 +) -> List[ShuffleApp]:
302 + """Fetch the Shuffle app catalog scoped to this customer's org.
303 +
304 + Used by the route form's app picker. Roundtrip is short (Shuffle
305 + returns the catalog quickly) and the result is small, so we don't
306 + cache — fresh data on every form open is fine for v1.
307 + """
308 + integration = await _ensure_integration_belongs_to_customer(integration_id, customer_code, session)
309 + base_url, api_key = await _get_shuffle_connector(session)
310 + ok, apps_raw, error = await shuffle_apps_client(
311 + base_url=base_url,
312 + api_key=api_key,
313 + org_id=integration.shuffle_org_id,
314 + )
315 + if not ok:
316 + raise HTTPException(
317 + status_code=502,
318 + detail=f"Failed to fetch apps from Shuffle: {error}",
319 + )
320 + # Forward only the fields the UI needs; ignore extra metadata that
321 + # Shuffle returns (versioning, ownership info, internal ids).
322 + apps: List[ShuffleApp] = []
323 + for raw in apps_raw:
324 + if not isinstance(raw, dict):
325 + continue
326 + if not raw.get("id") or not raw.get("name"):
327 + continue
328 + apps.append(
329 + ShuffleApp(
330 + id=str(raw.get("id")),
331 + name=str(raw.get("name")),
332 + description=raw.get("description"),
333 + large_image=raw.get("large_image"),
334 + ),
335 + )
336 + return apps
337 +
338 +
339 +async def verify_integration(integration_id: int, customer_code: str, session: AsyncSession) -> dict:
340 + integration = await _ensure_integration_belongs_to_customer(integration_id, customer_code, session)
341 + base_url, api_key = await _get_shuffle_connector(session)
342 + ok, app_count, error = await verify_shuffle_org_client(
343 + base_url=base_url,
344 + api_key=api_key,
345 + org_id=integration.shuffle_org_id,
346 + )
347 + return {
348 + "success": ok,
349 + "message": "Shuffle integration reachable" if ok else "Shuffle integration check failed",
350 + "org_id": integration.shuffle_org_id,
351 + "app_count": app_count,
352 + "error": error,
353 + }
354 +
355 +
356 +# ---------------------------------------------------------------------------
357 +# Dispatch log (read-only)
358 +# ---------------------------------------------------------------------------
359 +
360 +
361 +async def list_dispatch_log(
362 + customer_code: str,
363 + session: AsyncSession,
364 + limit: int = 100,
365 +) -> List[NotificationDispatchLog]:
366 + """Recent dispatch history for a customer. Defaults to 100 rows
367 + so the audit-log tab in the UI loads quickly even for noisy
368 + customers."""
369 + result = await session.execute(
370 + select(NotificationDispatchLog)
371 + .where(NotificationDispatchLog.customer_code == customer_code)
372 + .order_by(desc(NotificationDispatchLog.dispatched_at))
373 + .limit(limit),
374 + )
375 + return result.scalars().all()
376 +
377 +
378 +# ---------------------------------------------------------------------------
379 +# Dispatch — the core loop Talon invokes
380 +# ---------------------------------------------------------------------------
381 +
382 +
383 +def _severity_meets(report_severity: str, route_min: str) -> bool:
384 + """Inclusive severity comparison.
385 +
386 + A route with `min_severity="High"` fires when the report is High or
387 + Critical. SEVERITY_ORDER is sorted ascending — a higher index = more
388 + severe.
389 + """
390 + try:
391 + return SEVERITY_ORDER.index(report_severity) >= SEVERITY_ORDER.index(route_min)
392 + except ValueError:
393 + # Unknown severity string — fail closed. Better to drop a
394 + # notification than fire it on bad input.
395 + logger.warning(
396 + f"Unknown severity in routing comparison " f"(report={report_severity!r}, route_min={route_min!r}); " f"skipping route.",
397 + )
398 + return False
399 +
400 +
401 +def _trigger_applies(report_trigger: str, route_trigger: str, severity: str) -> bool:
402 + """Decide whether a route's trigger matches the dispatch.
403 +
404 + `investigation_complete` always matches (it's the catch-all).
405 + `severity_critical_or_high` only matches when the report severity
406 + is Critical or High.
407 + """
408 + if route_trigger != report_trigger:
409 + return False
410 + if route_trigger == NotificationTrigger.SEVERITY_CRITICAL_OR_HIGH.value:
411 + return severity in ("Critical", "High")
412 + return True
413 +
414 +
415 +def _format_default_body(req: DispatchRequest) -> str:
416 + """Plain default formatter when a route has no `format_template`.
417 +
418 + Markdown-ish but readable in both Slack and email — both channels
419 + render this acceptably without extra structure. Phase 4 swaps for
420 + per-channel templates.
421 + """
422 + parts = [
423 + f"*AI investigation complete* — severity: *{req.severity_assessment.value}*",
424 + "",
425 + f"Customer: `{req.customer_code}`",
426 + f"Alert: #{req.alert_id}" + (f" — {req.alert_name}" if req.alert_name else ""),
427 + "",
428 + req.summary.strip(),
429 + ]
430 + if req.report_url:
431 + parts.extend(["", f"Full report: {req.report_url}"])
432 + return "\n".join(parts)
433 +
434 +
435 +def _format_default_subject(req: DispatchRequest) -> str:
436 + return (
437 + f"[{req.severity_assessment.value}] AI investigation — "
438 + f"alert #{req.alert_id}"
439 + f"{(' ' + req.alert_name) if req.alert_name else ''}"
440 + )
441 +
442 +
443 +def _render_body(route: CustomerNotificationRoute, req: DispatchRequest) -> str:
444 + """Apply the route's `format_template` if set, else fall back.
445 +
446 + Phase 1's templating is intentionally minimal — `{{ variable }}`
447 + substitution only, no Jinja control flow. Real Jinja can come in
448 + Phase 4 when the per-channel templates land.
449 + """
450 + if not route.format_template:
451 + return _format_default_body(req)
452 +
453 + body = route.format_template
454 + substitutions = {
455 + "{{customer_code}}": req.customer_code,
456 + "{{alert_id}}": str(req.alert_id),
457 + "{{alert_name}}": req.alert_name or "",
458 + "{{severity}}": req.severity_assessment.value,
459 + "{{summary}}": req.summary,
460 + "{{report_url}}": req.report_url or "",
461 + }
462 + for token, value in substitutions.items():
463 + body = body.replace(token, value)
464 + return body
465 +
466 +
467 +async def _record_log(
468 + session: AsyncSession,
469 + *,
470 + customer_code: str,
471 + alert_id: int,
472 + route_id: int,
473 + trigger: str,
474 + status: str,
475 + error_message: Optional[str],
476 + latency_ms: Optional[int],
477 + payload_preview: Optional[str],
478 + shuffle_execution_id: Optional[str] = None,
479 +) -> bool:
480 + """Record a dispatch outcome. Returns False ONLY when the dispatch
481 + has already been recorded as `sent` — i.e. a true idempotency hit
482 + against a successful prior dispatch. Returns True in all other
483 + cases, including overwriting a previous failed/skipped attempt
484 + with the new result so retries land cleanly.
485 +
486 + Idempotency model:
487 + - One row per (customer_code, alert_id, route_id, trigger) tuple
488 + (enforced by a unique index)
489 + - If the existing row's status is `sent`, refuse the new write
490 + (caller treats as "already done, skip")
491 + - If the existing row's status is `failed`/`skipped`, overwrite
492 + with the new outcome — a previous failure must not block a
493 + retry
494 + - If no row exists yet, insert a fresh one
495 + """
496 + # Pre-flight: check whether a row already exists for this
497 + # (customer, alert, route, trigger) tuple. Doing the check up front
498 + # lets us update-in-place when needed — avoids the rollback path
499 + # whose `session.rollback()` expires every loaded object in the
500 + # session (route, integrations, etc.) and breaks subsequent
501 + # attribute access in async context.
502 + result = await session.execute(
503 + select(NotificationDispatchLog).where(
504 + NotificationDispatchLog.customer_code == customer_code,
505 + NotificationDispatchLog.alert_id == alert_id,
506 + NotificationDispatchLog.route_id == route_id,
507 + NotificationDispatchLog.trigger == trigger,
508 + ),
509 + )
510 + existing = result.scalars().first()
511 +
512 + if existing is not None and existing.status == "sent":
513 + # True idempotency hit — don't overwrite a successful dispatch.
514 + return False
515 +
516 + if existing is not None:
517 + # Previous failed/skipped attempt — overwrite it so the log
518 + # reflects the latest outcome and the retry path is clean.
519 + existing.status = status
520 + existing.error_message = error_message
521 + existing.latency_ms = latency_ms
522 + existing.payload_preview = payload_preview[:500] if payload_preview else None
523 + existing.shuffle_execution_id = shuffle_execution_id
524 + existing.dispatched_at = datetime.utcnow()
525 + await session.commit()
526 + return True
527 +
528 + # No prior record — insert fresh.
529 + log = NotificationDispatchLog(
530 + customer_code=customer_code,
531 + alert_id=alert_id,
532 + route_id=route_id,
533 + trigger=trigger,
534 + status=status,
535 + error_message=error_message,
536 + latency_ms=latency_ms,
537 + payload_preview=payload_preview[:500] if payload_preview else None,
538 + shuffle_execution_id=shuffle_execution_id,
539 + )
540 + session.add(log)
541 + try:
542 + await session.commit()
543 + return True
544 + except IntegrityError:
545 + # Race: another concurrent dispatch slipped in between our
546 + # SELECT and INSERT. Roll back, treat as idempotency hit. The
547 + # caller's outcome will be `skipped` and the route's attrs
548 + # will be expired — but the caller has already cached them
549 + # into locals so this is safe.
550 + await session.rollback()
551 + return False
552 +
553 +
554 +async def dispatch(req: DispatchRequest, session: AsyncSession) -> DispatchResponse:
555 + """Walk the customer's routes, fire each match, log each outcome.
556 +
557 + Idempotency is enforced at the log table — we attempt the insert
558 + *before* calling the provider, so a re-dispatch sees the existing
559 + row and short-circuits without sending. (The cost is one wasted
560 + INSERT in the race case, which is fine.)
561 + """
562 + routes = await list_routes(req.customer_code, session)
563 +
564 + matched_routes = [
565 + r
566 + for r in routes
567 + if r.enabled
568 + and _trigger_applies(req.trigger.value, r.trigger, req.severity_assessment.value)
569 + and _severity_meets(req.severity_assessment.value, r.min_severity)
570 + ]
571 +
572 + outcomes: List[DispatchOutcome] = []
573 + sent = failed = skipped = 0
574 +
575 + # Shuffle dispatch needs the deployment's connector creds. We fetch
576 + # them once per dispatch call (before the per-route loop) so a
577 + # whole batch of Shuffle routes shares one DB read. The fetch
578 + # itself is gated by "is any matched route Shuffle" — for SMTP-only
579 + # customers we never touch the connector row.
580 + shuffle_creds: Optional[tuple[str, str]] = None
581 + if any(r.channel == NotificationChannel.SHUFFLE.value for r in matched_routes):
582 + try:
583 + shuffle_creds = await _get_shuffle_connector(session)
584 + except HTTPException as e:
585 + # Connector misconfigured — the dispatch endpoint exposes
586 + # the helper's 503, but for a batch dispatch we'd rather
587 + # mark each Shuffle route as failed in the log than abort
588 + # the whole loop. SMTP routes in the same batch still go.
589 + logger.warning(f"Shuffle connector unavailable: {e.detail}")
590 + shuffle_creds = None
591 + shuffle_creds_error = str(e.detail)
592 + else:
593 + shuffle_creds_error = None
594 + else:
595 + shuffle_creds_error = None
596 +
597 + for route in matched_routes:
598 + # Cache every route attribute we'll need into locals UP FRONT.
599 + # Once we cross any `await` (let alone any rollback) the route
600 + # SQLAlchemy state can be expired and a synchronous attribute
601 + # access then triggers an implicit refresh query — which in
602 + # AsyncSession throws MissingGreenlet. Caching here means the
603 + # rest of the loop is plain-Python access on locals.
604 + route_id = route.id
605 + route_name = route.name
606 + route_channel = route.channel
607 + route_destination = route.destination
608 + route_shuffle_app_id = route.shuffle_app_id
609 + route_shuffle_integration_id = route.shuffle_integration_id
610 +
611 + body = _render_body(route, req)
612 + body_preview = body[:500]
613 +
614 + latency_ms: Optional[int] = None
615 + result_status = "sent"
616 + error_message: Optional[str] = None
617 + shuffle_execution_id: Optional[str] = None
618 +
619 + try:
620 + if route_channel == NotificationChannel.SMTP_EMAIL.value:
621 + recipients = [r.strip() for r in route_destination.split(",") if r.strip()]
622 + subject = _format_default_subject(req)
623 + result_status, error_message, latency_ms = await dispatch_smtp_email(recipients, subject, body)
624 + elif route_channel == NotificationChannel.SHUFFLE.value:
625 + # Phase 2: Shuffle hosted MCP. Fire-and-record — we POST
626 + # to /api/v1/apps/{app_id}/mcp with the deployment's
627 + # admin Bearer + the customer's Org-Id, capture the
628 + # execution_id, and consider the dispatch "sent" on
629 + # HTTP 200. We do NOT poll for the downstream app's
630 + # terminal state.
631 + if shuffle_creds is None:
632 + result_status = "failed"
633 + error_message = shuffle_creds_error or "Shuffle connector unavailable"
634 + latency_ms = 0
635 + elif not route_shuffle_app_id:
636 + result_status = "failed"
637 + error_message = "Route has no shuffle_app_id (data integrity issue)"
638 + latency_ms = 0
639 + else:
640 + integration = await session.get(CustomerShuffleIntegration, route_shuffle_integration_id)
641 + if not integration or integration.customer_code != req.customer_code:
642 + # Defense-in-depth: we already enforce tenant
643 + # isolation at create/update time, but a hand-
644 + # edited row could still slip through. Refusing
645 + # at dispatch time prevents cross-tenant leaks.
646 + result_status = "failed"
647 + error_message = (
648 + "Route's shuffle_integration is missing or belongs to a " "different customer; refusing to dispatch."
649 + )
650 + latency_ms = 0
651 + elif not integration.enabled:
652 + result_status = "skipped"
653 + error_message = "Shuffle integration is disabled"
654 + latency_ms = 0
655 + else:
656 + base_url, api_key = shuffle_creds
657 + # Cache integration attrs too — same reason as
658 + # the route caching above.
659 + integration_org_id = integration.shuffle_org_id
660 + # Shuffle's input_text is natural language. We
661 + # prepend a "send to {destination}" hint so the
662 + # Shuffle app agent knows where to deliver, and
663 + # follow with the formatted body.
664 + if route_destination:
665 + input_text = f"Send to {route_destination}: {body}"
666 + else:
667 + input_text = body
668 + (
669 + result_status,
670 + error_message,
671 + latency_ms,
672 + shuffle_execution_id,
673 + ) = await dispatch_shuffle(
674 + base_url=base_url,
675 + api_key=api_key,
676 + org_id=integration_org_id,
677 + app_id=route_shuffle_app_id,
678 + input_text=input_text,
679 + )
680 + else:
681 + # Unknown channel — preserved as a failure rather than
682 + # silently dropped so a misconfigured row surfaces in
683 + # the dispatch log.
684 + result_status = "failed"
685 + error_message = f"Unsupported channel: {route_channel}"
686 + latency_ms = None
687 + except Exception as e: # noqa: BLE001 — best-effort, never raise
688 + logger.exception(f"Dispatcher raised for route {route_id}: {e!r}")
689 + result_status = "failed"
690 + error_message = f"Dispatcher exception: {type(e).__name__}: {e}"
691 +
692 + # Record (or update) the dispatch outcome. _record_log handles
693 + # the retry-after-failure case in-place so a previous failed
694 + # row doesn't block a new attempt — the only way we get back
695 + # `False` here is a true idempotency hit on a previously-sent
696 + # dispatch.
697 + recorded = await _record_log(
698 + session,
699 + customer_code=req.customer_code,
700 + alert_id=req.alert_id,
701 + route_id=route_id,
702 + trigger=req.trigger.value,
703 + status=result_status,
704 + error_message=error_message,
705 + latency_ms=latency_ms,
706 + payload_preview=body_preview,
707 + shuffle_execution_id=shuffle_execution_id,
708 + )
709 +
710 + if not recorded:
711 + skipped += 1
712 + outcomes.append(
713 + DispatchOutcome(
714 + route_id=route_id,
715 + route_name=route_name,
716 + channel=route_channel,
717 + status=DispatchStatus.SKIPPED,
718 + error_message="Already dispatched (idempotency)",
719 + latency_ms=None,
720 + ),
721 + )
722 + continue
723 +
724 + # Maintain denorm columns for the UI list. Cheaper than joining
725 + # the log table on every render. We do this via an explicit
726 + # UPDATE statement rather than mutating the loaded route
727 + # object, so route's expiration state can't bite us.
728 + if result_status == "sent":
729 + sent += 1
730 + await session.execute(
731 + update(CustomerNotificationRoute)
732 + .where(CustomerNotificationRoute.id == route_id)
733 + .values(
734 + dispatch_count=CustomerNotificationRoute.dispatch_count + 1,
735 + last_dispatched_at=datetime.utcnow(),
736 + ),
737 + )
738 + # Bump the integration's last_used_at on a successful
739 + # Shuffle dispatch — gives the integration list a "fired
740 + # 2h ago" signal without a join against the log.
741 + if route_channel == NotificationChannel.SHUFFLE.value and route_shuffle_integration_id:
742 + await session.execute(
743 + update(CustomerShuffleIntegration)
744 + .where(CustomerShuffleIntegration.id == route_shuffle_integration_id)
745 + .values(last_used_at=datetime.utcnow()),
746 + )
747 + await session.commit()
748 + elif result_status == "skipped":
749 + skipped += 1
750 + else:
751 + failed += 1
752 +
753 + outcomes.append(
754 + DispatchOutcome(
755 + route_id=route_id,
756 + route_name=route_name,
757 + channel=route_channel,
758 + status=DispatchStatus(result_status),
759 + error_message=error_message,
760 + latency_ms=latency_ms,
761 + shuffle_execution_id=shuffle_execution_id,
762 + ),
763 + )
764 +
765 + return DispatchResponse(
766 + success=True,
767 + message=(f"Dispatched {sent} of {len(matched_routes)} matching route(s) " f"for customer {req.customer_code} alert {req.alert_id}"),
768 + routes_matched=len(matched_routes),
769 + dispatched=sent,
770 + skipped=skipped,
771 + failed=failed,
772 + outcomes=outcomes,
773 + )
backend/app/routers/notifications.py new
+11
@@ -0,0 +1,11 @@
1 +from fastapi import APIRouter
2 +
3 +from app.notifications.routes.notifications import notifications_router
4 +
5 +# Mount the notifications module under /api. Routes inside this router
6 +# already declare full paths (e.g. /customers/{code}/notification_routes,
7 +# /notifications/dispatch) so they end up at /api/customers/... and
8 +# /api/notifications/dispatch — matching the existing module pattern.
9 +router = APIRouter()
10 +
11 +router.include_router(notifications_router, tags=["notifications"])
backend/copilot.py
+2
@@ -66,6 +66,7 @@ from app.routers import mimecast
66 from app.routers import modules
67 from app.routers import monitoring_alert
68 from app.routers import network_connectors
69 +from app.routers import notifications
70 from app.routers import nuclei
71 from app.routers import office365
72 from app.routers import portainer
@@ -176,6 +177,7 @@ api_router.include_router(duo.router)
177 api_router.include_router(portainer.router)
178 api_router.include_router(incidents.router)
179 api_router.include_router(ai_analyst.router)
180 +api_router.include_router(notifications.router)
181 api_router.include_router(darktrace.router)
182 api_router.include_router(defenderforendpoint.router)
183 api_router.include_router(siem.router)
docs/architecture/SHUFFLE_NOTIFICATIONS.md new
+368
@@ -0,0 +1,368 @@
1 +# Shuffle MCP integration — per-customer notification routing
2 +
3 +Planning doc for the Shuffle integration. Lives here while the feature is in
4 +flight; gets folded into the architecture set or removed when the work
5 +ships and the user-facing docs land.
6 +
7 +**Branch:** `feat/shuffle-notifications`
8 +**Status:** Planning — no code yet.
9 +
10 +---
11 +
12 +## Problem
13 +
14 +Today, every Talon investigation writes back to the CoPilot database (job,
15 +report, IOCs). That's it. There's no per-customer notification fan-out —
16 +if one customer wants a Slack message on every true positive and another
17 +wants an Outlook email on Critical-only, neither path exists.
18 +
19 +Goals:
20 +
21 +1. Let each customer pick their own notification destinations (Slack, Outlook,
22 + Teams, email — eventually any of Shuffle's 3,000+ integrations).
23 +2. Route per-customer, with severity thresholds and trigger types.
24 +3. Don't change Talon's existing prompts or per-alert templates.
25 +4. Keep the MCP boundary uniform — the agent should reach notifications via
26 + the same stdio MCP pattern it already uses for `mysql`, `opensearch`, etc.
27 +
28 +Non-goals:
29 +
30 +- Replacing existing CoPilot integrations (Shuffle is for outbound notifications,
31 + not for swapping out our connectors).
32 +- Building our own integration catalog. We're consuming Shuffle's hosted MCP
33 + layer, not reinventing it.
34 +
35 +---
36 +
37 +## Architecture overview
38 +
39 +```
40 +┌──────────────────────────────────────────────────────────────────┐
41 +│ CoPilot frontend (Vue) │
42 +│ │
43 +│ Customers → [Acme] → Notifications tab │
44 +│ ┌───────────────────────────────────────────┐ │
45 +│ │ Connected integrations ← <ShuffleMCP> │ │
46 +│ │ • Slack • Outlook • Teams │ │
47 +│ ├───────────────────────────────────────────┤ │
48 +│ │ Routing rules │ │
49 +│ │ • Critical+ → Slack #soc-alerts │ │
50 +│ │ • High+ → Outlook ir@corp.com │ │
51 +│ └───────────────────────────────────────────┘ │
52 +└────────────────────────┬─────────────────────────────────────────┘
53 + │ REST (CoPilot DB)
54 + ▼
55 +┌──────────────────────────────────────────────────────────────────┐
56 +│ CoPilot backend (FastAPI) │
57 +│ │
58 +│ New tables: │
59 +│ customer_shuffle_integrations (per-customer Shuffle keys) │
60 +│ customer_notification_routes (severity → app → destination) │
61 +│ notification_dispatch_log (idempotency + audit) │
62 +│ │
63 +│ New routes: │
64 +│ GET/POST /customers/{code}/shuffle_integrations │
65 +│ GET/POST /customers/{code}/notification_routes │
66 +│ GET /customers/{code}/notification_dispatch_log │
67 +└────────────────────────┬─────────────────────────────────────────┘
68 + │ read-only MCP (mysql)
69 + ▼
70 +┌──────────────────────────────────────────────────────────────────┐
71 +│ Talon (NanoClaw) │
72 +│ │
73 +│ groups/copilot/.mcp.json gains: │
74 +│ "shuffle": "/workspace/extra/shuffle-mcp/shuffle-mcp.sh" │
75 +│ │
76 +│ shuffle-mcp/shuffle-mcp.py exposes: │
77 +│ shuffle_list_apps(customer_code) │
78 +│ shuffle_invoke(customer_code, app, input) │
79 +│ shuffle_dispatch_notifications(customer_code, alert_id, │
80 +│ trigger, severity, summary) │
81 +│ │
82 +│ groups/copilot/CLAUDE.md gains a single new instruction: │
83 +│ "After report write-back, call │
84 +│ shuffle_dispatch_notifications(...) — best effort, log │
85 +│ failures, never fail the investigation." │
86 +└────────────────────────┬─────────────────────────────────────────┘
87 + │ HTTP (JSON-RPC)
88 + ▼
89 +┌──────────────────────────────────────────────────────────────────┐
90 +│ Shuffle (hosted) │
91 +│ https://shuffler.io/api/v1/apps/{app}/mcp │
92 +│ Authorization: Bearer <per-customer Shuffle key> │
93 +└──────────────────────────────────────────────────────────────────┘
94 +```
95 +
96 +---
97 +
98 +## Phase 1 — Manual webhooks, no Shuffle yet
99 +
100 +**Why first:** ships value before any third-party dependency. Validates the
101 +table shape, the dispatch loop, and the agent's "after write-back, fan out"
102 +instruction.
103 +
104 +### Backend
105 +
106 +#### Tables
107 +
108 +```python
109 +class CustomerNotificationRoute(SQLModel, table=True):
110 + __tablename__ = "customer_notification_routes"
111 +
112 + id: int | None = Field(default=None, primary_key=True)
113 + customer_code: str = Field(foreign_key="customers.customer_code", index=True)
114 +
115 + name: str # human label, e.g. "SOC team Slack #alerts"
116 + trigger: str # 'investigation_true_positive', 'severity_critical', ...
117 + channel: str # Phase 1 set: 'smtp_email' only. Phase 2 adds 'shuffle'.
118 + destination: str # webhook URL or email address
119 + min_severity: str # 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational'
120 + format_template: str | None = None # optional Jinja override
121 +
122 + enabled: bool = True
123 +
124 + last_dispatched_at: datetime | None = None # denorm for UI list
125 + dispatch_count: int = 0 # denorm counter
126 + created_by: str | None = None # CoPilot user who added it
127 +
128 + created_at: datetime = Field(default_factory=datetime.utcnow)
129 + updated_at: datetime = Field(default_factory=datetime.utcnow)
130 +```
131 +
132 +```python
133 +class NotificationDispatchLog(SQLModel, table=True):
134 + __tablename__ = "notification_dispatch_log"
135 + __table_args__ = (
136 + UniqueConstraint(
137 + "customer_code", "alert_id", "route_id", "trigger",
138 + name="uq_notif_dispatch_idem",
139 + ),
140 + )
141 +
142 + id: int | None = Field(default=None, primary_key=True)
143 + customer_code: str = Field(index=True)
144 + alert_id: int = Field(index=True)
145 + route_id: int = Field(foreign_key="customer_notification_routes.id")
146 + trigger: str
147 +
148 + dispatched_at: datetime = Field(default_factory=datetime.utcnow)
149 + status: str # 'sent' | 'failed' | 'skipped'
150 + error_message: str | None = None
151 + latency_ms: int | None = None
152 + payload_preview: str | None = None # first 500 chars, debugging
153 +```
154 +
155 +#### Deferred / dropped
156 +
157 +- **`anonymize`** — dropped. Recipients are SOC analysts who already see
158 + deanonymized reports in the UI; toggle has no consumer.
159 +- **`tags`**, **`payload_filter`**, **`rate_limit_per_minute`** — deferred.
160 + `trigger` + `min_severity` covers the 80% case. Phase 4 can layer on
161 + richer filters / rate limits without touching this schema (rate limit
162 + derivable from a windowed count over the dispatch log).
163 +
164 +#### Wiring
165 +
166 +- Alembic migration creates both tables
167 +- Pydantic schemas in `app/notifications/schema.py`
168 +- CRUD service + REST routes (`/customers/{code}/notification_routes`)
169 +- Initial dispatch helper: `dispatch_smtp_email(to, subject, body)` —
170 + SMTP only. Slack/Teams/etc. arrive in Phase 2 via Shuffle's hosted
171 + MCP rather than as raw webhook URLs in CoPilot, since Phase 2's
172 + picker-based OAuth replaces the manual-paste UX entirely. Shipping
173 + `slack_webhook` as a Phase 1 channel would have been throwaway UI.
174 +- Logger writes to `notification_dispatch_log` with the unique-index
175 + upsert pattern for idempotency
176 +
177 +### Frontend
178 +
179 +- Customer detail page → new "Notifications" tab
180 +- Form: pick channel (Slack/email), enter webhook URL or email, severity
181 + threshold, trigger type
182 +- List view of existing routes with enable/disable toggle
183 +- Dispatch log viewer (read-only)
184 +
185 +### Talon
186 +
187 +- Add a single new section to `groups/copilot/CLAUDE.md`:
188 + > **After report write-back**, query `customer_notification_routes` for the
189 + > alert's `customer_code` filtered by trigger and severity. For each
190 + > enabled row, format the summary per the route's template (default:
191 + > severity + alert link + summary) and POST the webhook / send the email.
192 + > Notifications are **best-effort** — log success/failure to
193 + > `notification_dispatch_log` keyed by `(customer_code, alert_id, route_id,
194 + > trigger)`. Skip if the log already has a row for that key (idempotency).
195 + > Do **not** fail the investigation on dispatch errors.
196 +- The agent uses its existing tools (MySQL MCP for the route lookup + log
197 + write, Bash with curl for the webhook POST). No new Talon-side MCP yet.
198 +
199 +### Acceptance
200 +
201 +- Set `SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM` (and creds if required) in CoPilot's environment
202 +- Configure an SMTP route on one customer (e.g. `severity_critical_or_high` → `soc@example.com`)
203 +- Trigger an investigation that resolves Critical or High
204 +- Email arrives within ~10s of report write-back
205 +- `notification_dispatch_log` has the row
206 +- Re-running the same investigation does not re-fire the email
207 +
208 +---
209 +
210 +## Phase 2 — Shuffle proxy MCP in Talon
211 +
212 +**Why:** unlocks the 3,000+ catalog without forcing the agent to learn each
213 +provider's REST API. Single stdio MCP, same boundary as `mysql-mcp.sh`.
214 +
215 +### Talon
216 +
217 +- New directory: `nanoclaw/shuffle-mcp/`
218 + - `shuffle-mcp.sh` — bash wrapper (loads `.env`, exec's the python entry)
219 + - `shuffle-mcp.py` — stdio MCP server using the standard MCP Python SDK
220 + - `setup.sh` — install/activate per the existing pattern
221 + - `CLAUDE.md` — short tool-selection guide for the agent
222 +- Tools exposed:
223 +
224 + | Tool | Purpose |
225 + |------|---------|
226 + | `shuffle_list_apps(customer_code)` | Return the customer's authenticated apps + a one-line description from the Shuffle catalog. Used at runtime so the agent picks intelligently. |
227 + | `shuffle_invoke(customer_code, app, input)` | POST to `https://shuffler.io/api/v1/apps/{app}/mcp` with the customer's Bearer key. `input` is the natural-language string Shuffle expects. |
228 + | `shuffle_dispatch_notifications(customer_code, alert_id, trigger, severity, summary)` | High-level convenience: looks up routes for the customer, formats per channel, calls `shuffle_invoke` for each, writes the dispatch log. Idempotent. |
229 +
230 +- API key sourcing: the MCP queries CoPilot's MySQL for
231 + `customer_shuffle_integrations.api_key WHERE customer_code = ?`. **Never**
232 + trusts a `customer_code` parameter from the agent for cross-tenant lookups
233 + — the MCP enforces the tenant boundary, not the prompt.
234 +- Container build: install `shuffle-mcp` into a venv via
235 + `container/Dockerfile`, like the existing `opensearch-mcp` / `mempalace`
236 + pattern.
237 +
238 +### CoPilot backend
239 +
240 +- Alembic migration: `customer_shuffle_integrations`
241 + - `id`, `customer_code` (FK), `app` (text, e.g. "slack"),
242 + `display_name`, `api_key` (encrypted), `connected_at`, `last_used_at`,
243 + `enabled`
244 +- REST: CRUD for integrations + a "test" route that calls Shuffle to verify
245 + the key works (`tools/list` against the app's MCP endpoint)
246 +- `customer_notification_routes` gains a `shuffle_app` column referencing
247 + the integration. Phase 1's manual `channel`/`destination` columns become
248 + optional — routes use one or the other.
249 +
250 +### Talon prompt change
251 +
252 +Replace Phase 1's "POST the webhook" instruction with:
253 +
254 +> **After report write-back**, call
255 +> `shuffle_dispatch_notifications(customer_code, alert_id, trigger,
256 +> severity, summary)`. The MCP handles routing, formatting, and the
257 +> dispatch log internally. Best-effort — failures already logged.
258 +
259 +Single tool call from the agent's perspective. The MCP owns the per-channel
260 +formatting + idempotency + tenant scoping.
261 +
262 +### Acceptance
263 +
264 +- Manually insert a row in `customer_shuffle_integrations` for one customer
265 + with a real Shuffle API key
266 +- Investigation completes → agent calls
267 + `shuffle_dispatch_notifications` → Slack message arrives via Shuffle (not
268 + via raw webhook)
269 +- Verify the dispatch log entry shows `app=slack` and references the
270 + Shuffle integration row, not a raw URL
271 +
272 +---
273 +
274 +## Phase 3 — Shuffle picker in CoPilot frontend
275 +
276 +**Why:** removes the manual API key paste. Customers self-serve via the
277 +embedded picker.
278 +
279 +### Frontend
280 +
281 +- Install `@shuffleio/shuffle-mcps` (peer deps already met by Vue side via
282 + the Vue export `@singulio/singul/vue`)
283 +- Replace the manual "API key" input on the Notifications tab with the
284 + `<ShuffleMCP>` (or Vue equivalent) component
285 +- On `onAppSelected`, kick off Shuffle's OAuth flow, capture the resulting
286 + Bearer key, POST it to CoPilot's `/customers/{code}/shuffle_integrations`
287 +- Show connected integrations as cards; "Disconnect" button revokes the
288 + CoPilot row (does not revoke at Shuffle — admin must do that themselves
289 + via shuffler.io)
290 +
291 +### Backend
292 +
293 +- No schema change — the picker just writes through the existing
294 + `customer_shuffle_integrations` endpoint
295 +- Optional: webhook receiver for Shuffle revocation events (later)
296 +
297 +### Acceptance
298 +
299 +- A customer admin opens the Notifications tab → clicks "+ Add integration"
300 + → picker shows 3,000+ apps → picks Slack → OAuth pops → returns a key
301 + stored in CoPilot
302 +- A new notification route can immediately reference this integration
303 +
304 +---
305 +
306 +## Phase 4 — Hardening
307 +
308 +- **Per-channel format templates:** Slack gets compact + thread, email gets
309 + full markdown, Teams gets adaptive card. Default templates in
310 + `shuffle-mcp/templates/{channel}.j2`. Routes can override via
311 + `format_template`.
312 +- **Retry semantics:** failed dispatches retry once after 30s, then mark
313 + failed. Logged in `notification_dispatch_log.status='failed'` with the
314 + upstream error.
315 +- **Audit trail UI:** Customer → Notifications → Dispatch log tab shows
316 + recent fires, status, retry count.
317 +- **Rate limiting per customer:** prevent runaway dispatch storms (e.g. a
318 + detection rule firing 100x/min) — coalesce to 1 dispatch per minute per
319 + route, summarize the rest.
320 +
321 +---
322 +
323 +## Cross-cutting concerns
324 +
325 +| Concern | Decision |
326 +|---------|----------|
327 +| **Tenant isolation** | The Shuffle MCP itself is a stateless adapter — same `/apps/slack` URL for every customer. Isolation lives in two CoPilot-side places: (1) which `customer_shuffle_integrations.api_key` row gets fetched (Bearer token differs per customer's OAuth-issued workspace), (2) which `customer_notification_routes.destination` (channel name / email) is used. Both are filtered by `customer_code` at lookup time — single SQLAlchemy boundary. Cross-tenant leak risk is "did the lookup pull the right customer's row" — covered by the FK + an explicit test. |
328 +| **Shuffle outage** | Notification step wrapped in try/except; failure does not fail the investigation. Logged to `notification_dispatch_log.status='failed'`. |
329 +| **PII** | Recipients are SOC analysts who already see deanonymized reports in the CoPilot UI. No anonymization layer needed. The `anonymize` column from earlier drafts has been dropped. |
330 +| **Idempotency** | Unique index on `(customer_code, alert_id, route_id, trigger)`. Agent's instruction is "skip if log row already exists." Re-runs are safe. |
331 +| **Format mismatch** | Default templates per channel. Custom override per route via `format_template`. Phase 4 ships the default template set. |
332 +| **Cost** | Shuffle's per-call pricing exists but isn't blocking — revisit once we have real volume. Phase 4's coalescing/rate-limit work covers it preemptively if needed. |
333 +| **Failure mode visibility** | `notification_dispatch_log` is the source of truth. Frontend surfaces it. |
334 +
335 +---
336 +
337 +## Open questions
338 +
339 +1. **Shuffle key revocation** — does Shuffle expose a webhook when a user
340 + revokes upstream? Need this for clean state in CoPilot.
341 +2. **Shuffle's `tools/list` schema** — does each app expose typed tool
342 + schemas, or only the natural-language `tool_name` + `input` shape? If
343 + typed, Phase 2 can register N tools per app instead of one generic
344 + `shuffle_invoke`. Worth a 30-min spike before locking Phase 2's design.
345 +
346 +---
347 +
348 +## Out of scope (for now)
349 +
350 +- Inbound: Talon receiving messages back through Shuffle (Shuffle → Talon).
351 + Possible future use: slash commands in Slack to trigger investigations.
352 + Not Phase 1–4.
353 +- Replacing CoPilot's existing alerting (Graylog → CoPilot).
354 +- Bidirectional state (closing an alert from Slack).
355 +
356 +---
357 +
358 +## Summary of phasing
359 +
360 +| Phase | Duration estimate | Ships |
361 +|-------|-------------------|-------|
362 +| **1** | ~3 days | Working notifications via plain webhooks/SMTP. Schema + agent loop validated. |
363 +| **2** | ~3 days | Shuffle MCP in Talon. 3,000+ apps reachable via the catalog. |
364 +| **3** | ~2 days | Picker in CoPilot. Customer self-service. |
365 +| **4** | ~3 days | Templates, anonymize, retry, audit, rate limit. |
366 +
367 +Total ~11 working days end-to-end. Phase 1 is the only one that materially
368 +touches Talon's prompt; Phases 2–4 are additive on the MCP / DB / UI sides.
frontend/src/api/endpoints/notifications.ts new
+95
@@ -0,0 +1,95 @@
1 +import type {
2 + NotificationDispatchLogEntry,
3 + NotificationRoute,
4 + NotificationRoutePayload,
5 + NotificationRouteUpdatePayload,
6 + ShuffleApp,
7 + ShuffleIntegration,
8 + ShuffleIntegrationPayload,
9 + ShuffleIntegrationUpdatePayload,
10 + ShuffleVerifyResult
11 +} from "@/types/notifications.d"
12 +import type { FlaskBaseResponse } from "@/types/flask.d"
13 +import { HttpClient } from "../httpClient"
14 +
15 +// Per-customer notification routing — wraps app/notifications/routes/notifications.py.
16 +// Used by the Customer detail page's "AI Notifications" tab to manage who
17 +// receives notifications about Talon's investigation results.
18 +
19 +export default {
20 + listRoutes(customerCode: string) {
21 + return HttpClient.get<FlaskBaseResponse & { routes: NotificationRoute[] }>(
22 + `/customers/${customerCode}/notification_routes`
23 + )
24 + },
25 +
26 + createRoute(customerCode: string, payload: NotificationRoutePayload) {
27 + return HttpClient.post<FlaskBaseResponse & { route: NotificationRoute }>(
28 + `/customers/${customerCode}/notification_routes`,
29 + payload
30 + )
31 + },
32 +
33 + updateRoute(customerCode: string, routeId: number, payload: NotificationRouteUpdatePayload) {
34 + return HttpClient.patch<FlaskBaseResponse & { route: NotificationRoute }>(
35 + `/customers/${customerCode}/notification_routes/${routeId}`,
36 + payload
37 + )
38 + },
39 +
40 + deleteRoute(customerCode: string, routeId: number) {
41 + return HttpClient.delete<FlaskBaseResponse>(
42 + `/customers/${customerCode}/notification_routes/${routeId}`
43 + )
44 + },
45 +
46 + listDispatchLog(customerCode: string) {
47 + return HttpClient.get<FlaskBaseResponse & { entries: NotificationDispatchLogEntry[] }>(
48 + `/customers/${customerCode}/notification_dispatch_log`
49 + )
50 + },
51 +
52 + // ----- Shuffle integrations (Phase 2) -----
53 +
54 + listShuffleIntegrations(customerCode: string) {
55 + return HttpClient.get<FlaskBaseResponse & { integrations: ShuffleIntegration[] }>(
56 + `/customers/${customerCode}/shuffle_integrations`
57 + )
58 + },
59 +
60 + createShuffleIntegration(customerCode: string, payload: ShuffleIntegrationPayload) {
61 + return HttpClient.post<FlaskBaseResponse & { integration: ShuffleIntegration }>(
62 + `/customers/${customerCode}/shuffle_integrations`,
63 + payload
64 + )
65 + },
66 +
67 + updateShuffleIntegration(
68 + customerCode: string,
69 + integrationId: number,
70 + payload: ShuffleIntegrationUpdatePayload
71 + ) {
72 + return HttpClient.patch<FlaskBaseResponse & { integration: ShuffleIntegration }>(
73 + `/customers/${customerCode}/shuffle_integrations/${integrationId}`,
74 + payload
75 + )
76 + },
77 +
78 + deleteShuffleIntegration(customerCode: string, integrationId: number) {
79 + return HttpClient.delete<FlaskBaseResponse>(
80 + `/customers/${customerCode}/shuffle_integrations/${integrationId}`
81 + )
82 + },
83 +
84 + listShuffleApps(customerCode: string, integrationId: number) {
85 + return HttpClient.get<FlaskBaseResponse & { apps: ShuffleApp[] }>(
86 + `/customers/${customerCode}/shuffle_integrations/${integrationId}/apps`
87 + )
88 + },
89 +
90 + verifyShuffleIntegration(customerCode: string, integrationId: number) {
91 + return HttpClient.get<FlaskBaseResponse & ShuffleVerifyResult>(
92 + `/customers/${customerCode}/shuffle_integrations/${integrationId}/verify`
93 + )
94 + }
95 +}
frontend/src/api/index.ts
+2
@@ -23,6 +23,7 @@ import logs from "./endpoints/logs"
23 import metrics from "./endpoints/metrics"
24 import monitoringAlerts from "./endpoints/monitoringAlerts"
25 import networkConnectors from "./endpoints/networkConnectors"
26 +import notifications from "./endpoints/notifications"
27 import patchTuesday from "./endpoints/patchTuesday"
28 import portainer from "./endpoints/portainer"
29 import reporting from "./endpoints/reporting"
@@ -74,6 +75,7 @@ export default {
75 githubAudit,
76 scheduler,
77 networkConnectors,
78 + notifications,
79 copilotSearches,
80 cloudSecurityAssessment,
81 webVulnerabilityAssessment,
frontend/src/components/customers/CustomerItem.vue
+6
@@ -188,6 +188,9 @@
188 <n-tab-pane name="AI Triggers" tab="AI Triggers" display-directive="show:lazy">
189 <CustomerAITriggers :customer-code="customer.customer_code" />
190 </n-tab-pane>
191 + <n-tab-pane name="AI Notifications" tab="AI Notifications" display-directive="show:lazy">
192 + <CustomerAiNotifications :customer-code="customer.customer_code" />
193 + </n-tab-pane>
194 <n-tab-pane name="Event Sources" tab="Event Sources" display-directive="show:lazy">
195 <CustomerEventSources :customer-code="customer.customer_code" />
196 </n-tab-pane>
@@ -304,6 +307,9 @@ const CustomerNotificationsWorkflows = defineAsyncComponent(
307 () => import("./notifications/CustomerNotificationsWorkflows.vue")
308 )
309 const CustomerAITriggers = defineAsyncComponent(() => import("./aiTriggers/CustomerAITriggers.vue"))
310 +const CustomerAiNotifications = defineAsyncComponent(
311 + () => import("./aiNotifications/CustomerAiNotifications.vue")
312 +)
313 const CustomerEventSources = defineAsyncComponent(() => import("./eventSources/CustomerEventSources.vue"))
314 const CustomerWazuhWorker = defineAsyncComponent(() => import("./CustomerWazuhWorker.vue"))
315
frontend/src/components/customers/aiNotifications/CustomerAiNotificationDispatchLog.vue new
+142
@@ -0,0 +1,142 @@
1 +<template>
2 + <div class="customer-ai-notification-dispatch-log">
3 + <div class="flex items-center justify-between gap-4 px-7 pt-2">
4 + <div class="text-secondary text-sm">
5 + Recent notification dispatches for this customer (newest first, capped at 100).
6 + </div>
7 + <n-button size="small" :disabled="loading" @click="refreshList()">
8 + <template #icon>
9 + <Icon :name="RefreshIcon" :size="14" />
10 + </template>
11 + Refresh
12 + </n-button>
13 + </div>
14 +
15 + <n-spin :show="loading">
16 + <div class="min-h-52 p-7 pt-4">
17 + <n-empty
18 + v-if="!loading && !entries.length"
19 + description="No dispatches yet"
20 + class="h-48 justify-center"
21 + />
22 +
23 + <n-data-table
24 + v-else-if="entries.length"
25 + :columns="columns"
26 + :data="entries"
27 + size="small"
28 + :bordered="false"
29 + :row-key="(r: DispatchLogEntry) => r.id"
30 + />
31 + </div>
32 + </n-spin>
33 + </div>
34 +</template>
35 +
36 +<script setup lang="ts">
37 +import type { NotificationDispatchLogEntry as DispatchLogEntry } from "@/types/notifications.d"
38 +import type { DataTableColumns } from "naive-ui"
39 +import { NButton, NDataTable, NEmpty, NSpin, useMessage } from "naive-ui"
40 +import { computed, h, onBeforeMount, ref } from "vue"
41 +import Api from "@/api"
42 +import Badge from "@/components/common/Badge.vue"
43 +import Icon from "@/components/common/Icon.vue"
44 +import { getApiErrorMessage } from "@/utils"
45 +import { formatDate } from "@/utils/format"
46 +
47 +const { customerCode } = defineProps<{
48 + customerCode: string
49 +}>()
50 +
51 +const RefreshIcon = "carbon:renew"
52 +
53 +const message = useMessage()
54 +const loading = ref(false)
55 +const entries = ref<DispatchLogEntry[]>([])
56 +
57 +function statusColor(status: string): "success" | "warning" | "danger" | undefined {
58 + if (status === "sent") return "success"
59 + if (status === "skipped") return "warning"
60 + if (status === "failed") return "danger"
61 + return undefined
62 +}
63 +
64 +const columns = computed<DataTableColumns<DispatchLogEntry>>(() => [
65 + {
66 + title: "When",
67 + key: "dispatched_at",
68 + width: 160,
69 + render: row => formatDate(row.dispatched_at, "MMM D, YYYY HH:mm:ss")
70 + },
71 + {
72 + title: "Alert",
73 + key: "alert_id",
74 + width: 80,
75 + render: row => `#${row.alert_id}`
76 + },
77 + {
78 + title: "Trigger",
79 + key: "trigger",
80 + width: 220,
81 + render: row =>
82 + row.trigger === "investigation_complete" ? "Every investigation" : "Critical / High only"
83 + },
84 + {
85 + title: "Status",
86 + key: "status",
87 + width: 110,
88 + render: row =>
89 + h(
90 + Badge,
91 + { type: "splitted", color: statusColor(row.status) },
92 + {
93 + label: () => "Status",
94 + value: () => row.status
95 + }
96 + )
97 + },
98 + {
99 + title: "Latency",
100 + key: "latency_ms",
101 + width: 100,
102 + render: row => (row.latency_ms == null ? "—" : `${row.latency_ms} ms`)
103 + },
104 + {
105 + title: "Error / Preview",
106 + key: "detail",
107 + // Long column — collapses content with title on hover for full text.
108 + render: row => {
109 + const text = row.error_message || row.payload_preview || ""
110 + return h(
111 + "div",
112 + {
113 + class: "truncate max-w-md",
114 + title: text
115 + },
116 + text
117 + )
118 + }
119 + }
120 +])
121 +
122 +function refreshList() {
123 + loading.value = true
124 + Api.notifications
125 + .listDispatchLog(customerCode)
126 + .then(res => {
127 + if (res.data.success) {
128 + entries.value = res.data.entries
129 + } else {
130 + message.warning(res.data.message || "Failed to load dispatch log")
131 + }
132 + })
133 + .catch(err => {
134 + message.error(getApiErrorMessage(err) || "Failed to load dispatch log")
135 + })
136 + .finally(() => {
137 + loading.value = false
138 + })
139 +}
140 +
141 +onBeforeMount(refreshList)
142 +</script>
frontend/src/components/customers/aiNotifications/CustomerAiNotificationRouteForm.vue new
+373
@@ -0,0 +1,373 @@
1 +<template>
2 + <n-form
3 + ref="formRef"
4 + :model="form"
5 + :rules="rules"
6 + label-placement="top"
7 + class="px-7 py-4"
8 + >
9 + <div class="mb-3 flex items-center justify-between">
10 + <h3 class="text-lg font-medium">{{ editing ? "Edit route" : "Add route" }}</h3>
11 + <n-button size="small" quaternary @click="$emit('close')">
12 + <template #icon>
13 + <Icon :name="CloseIcon" :size="14" />
14 + </template>
15 + Cancel
16 + </n-button>
17 + </div>
18 +
19 + <n-form-item label="Name" path="name">
20 + <n-input
21 + v-model:value="form.name"
22 + placeholder="e.g. SOC team Slack #alerts"
23 + :maxlength="128"
24 + show-count
25 + />
26 + </n-form-item>
27 +
28 + <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
29 + <n-form-item label="Trigger" path="trigger">
30 + <n-select v-model:value="form.trigger" :options="triggerOptions" />
31 + </n-form-item>
32 +
33 + <n-form-item label="Minimum severity" path="min_severity">
34 + <n-select v-model:value="form.min_severity" :options="severityOptions" />
35 + </n-form-item>
36 + </div>
37 +
38 + <n-form-item label="Channel" path="channel">
39 + <n-select v-model:value="form.channel" :options="channelOptions" @update:value="onChannelChange" />
40 + <template #feedback>
41 + <span class="text-tertiary text-xs">
42 + Email is direct SMTP via CoPilot's deployment config. Shuffle proxies to
43 + 3,000+ integrations through a customer's authenticated Shuffle org.
44 + </span>
45 + </template>
46 + </n-form-item>
47 +
48 + <!-- SMTP-specific: recipient emails -->
49 + <n-form-item v-if="form.channel === 'smtp_email'" label="Recipient email(s)" path="destination">
50 + <n-input
51 + v-model:value="form.destination"
52 + placeholder="soc@example.com, ir@example.com"
53 + type="text"
54 + />
55 + </n-form-item>
56 +
57 + <!-- Shuffle-specific: integration picker, app picker, destination hint -->
58 + <template v-if="form.channel === 'shuffle'">
59 + <n-form-item label="Shuffle integration" path="shuffle_integration_id">
60 + <div class="flex w-full flex-col gap-1">
61 + <n-select
62 + v-model:value="form.shuffle_integration_id"
63 + :options="integrationOptions"
64 + placeholder="Pick a Shuffle org for this customer"
65 + :loading="loadingIntegrations"
66 + @update:value="onIntegrationChange"
67 + />
68 + <div v-if="!integrationOptions.length && !loadingIntegrations" class="text-tertiary text-xs">
69 + No Shuffle integrations configured for this customer yet — go to the
70 + <strong>Shuffle integrations</strong> tab to add one first.
71 + </div>
72 + </div>
73 + </n-form-item>
74 +
75 + <n-form-item label="Shuffle app" path="shuffle_app_id">
76 + <div class="flex w-full flex-col gap-1">
77 + <n-select
78 + v-model:value="form.shuffle_app_id"
79 + :options="appOptions"
80 + placeholder="Pick an authenticated app"
81 + :loading="loadingApps"
82 + :disabled="!form.shuffle_integration_id || loadingApps"
83 + filterable
84 + @update:value="onAppChange"
85 + />
86 + <div
87 + v-if="form.shuffle_integration_id && !appOptions.length && !loadingApps && appsError"
88 + class="text-error text-xs"
89 + >
90 + Couldn't fetch apps from Shuffle: {{ appsError }}
91 + </div>
92 + </div>
93 + </n-form-item>
94 +
95 + <n-form-item label="Destination hint" path="destination">
96 + <n-input
97 + v-model:value="form.destination"
98 + placeholder="e.g. #soc-alerts, soc@example.com, @user-id"
99 + type="text"
100 + />
101 + <template #feedback>
102 + <span class="text-tertiary text-xs">
103 + Free-form — gets prepended to the outgoing message as a
104 + <code>Send to &lt;destination&gt;: …</code> hint so the Shuffle app agent
105 + knows where to deliver. Channel name for Slack, email for Outlook, etc.
106 + </span>
107 + </template>
108 + </n-form-item>
109 + </template>
110 +
111 + <n-form-item label="Custom message template (optional)" path="format_template">
112 + <n-input
113 + v-model:value="form.format_template"
114 + type="textarea"
115 + :autosize="{ minRows: 4, maxRows: 12 }"
116 + placeholder="Leave empty to use the default. Substitutions: {{customer_code}} {{alert_id}} {{alert_name}} {{severity}} {{summary}} {{report_url}}"
117 + />
118 + </n-form-item>
119 +
120 + <n-form-item>
121 + <n-checkbox v-model:checked="form.enabled">Enabled</n-checkbox>
122 + </n-form-item>
123 +
124 + <div class="flex justify-end gap-2">
125 + <n-button @click="$emit('close')">Cancel</n-button>
126 + <n-button type="primary" :loading="submitting" @click="submit">
127 + {{ editing ? "Save changes" : "Create route" }}
128 + </n-button>
129 + </div>
130 + </n-form>
131 +</template>
132 +
133 +<script setup lang="ts">
134 +import type {
135 + NotificationChannel,
136 + NotificationRoute,
137 + NotificationRoutePayload,
138 + NotificationSeverity,
139 + NotificationTrigger,
140 + ShuffleApp,
141 + ShuffleIntegration
142 +} from "@/types/notifications.d"
143 +import type { FormInst, FormRules } from "naive-ui"
144 +import { NButton, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
145 +import { computed, onBeforeMount, reactive, ref } from "vue"
146 +import Api from "@/api"
147 +import Icon from "@/components/common/Icon.vue"
148 +import { getApiErrorMessage } from "@/utils"
149 +
150 +const props = defineProps<{
151 + customerCode: string
152 + editingRoute: NotificationRoute | null
153 +}>()
154 +
155 +const emit = defineEmits<{
156 + (e: "submitted"): void
157 + (e: "close"): void
158 +}>()
159 +
160 +const CloseIcon = "carbon:close"
161 +
162 +const message = useMessage()
163 +const formRef = ref<FormInst | null>(null)
164 +const submitting = ref(false)
165 +
166 +const editing = computed(() => props.editingRoute !== null)
167 +
168 +const form = reactive<NotificationRoutePayload>({
169 + name: props.editingRoute?.name ?? "",
170 + trigger: props.editingRoute?.trigger ?? ("investigation_complete" as NotificationTrigger),
171 + channel: props.editingRoute?.channel ?? ("smtp_email" as NotificationChannel),
172 + destination: props.editingRoute?.destination ?? "",
173 + min_severity: props.editingRoute?.min_severity ?? ("Medium" as NotificationSeverity),
174 + format_template: props.editingRoute?.format_template ?? "",
175 + enabled: props.editingRoute?.enabled ?? true,
176 + shuffle_integration_id: props.editingRoute?.shuffle_integration_id ?? null,
177 + shuffle_app_id: props.editingRoute?.shuffle_app_id ?? null,
178 + shuffle_app_name: props.editingRoute?.shuffle_app_name ?? null
179 +})
180 +
181 +const triggerOptions = [
182 + { label: "Every investigation completes", value: "investigation_complete" },
183 + { label: "Critical / High severity only", value: "severity_critical_or_high" }
184 +]
185 +
186 +const channelOptions = [
187 + { label: "Email (SMTP)", value: "smtp_email" },
188 + { label: "Shuffle (Slack / Teams / Outlook / 3,000+ apps)", value: "shuffle" }
189 +]
190 +
191 +const severityOptions = [
192 + { label: "Critical (only)", value: "Critical" },
193 + { label: "High and above", value: "High" },
194 + { label: "Medium and above", value: "Medium" },
195 + { label: "Low and above", value: "Low" },
196 + { label: "Informational and above (everything)", value: "Informational" }
197 +]
198 +
199 +// Shuffle integrations + apps state. Integrations are fetched on form
200 +// open; apps are fetched lazily when an integration is picked. Both
201 +// short-circuit on edit so we don't blank a route's existing values.
202 +const integrations = ref<ShuffleIntegration[]>([])
203 +const loadingIntegrations = ref(false)
204 +const apps = ref<ShuffleApp[]>([])
205 +const loadingApps = ref(false)
206 +const appsError = ref<string | null>(null)
207 +
208 +const integrationOptions = computed(() =>
209 + integrations.value
210 + .filter(i => i.enabled)
211 + .map(i => ({
212 + label: `${i.display_name} (${i.shuffle_org_id.slice(0, 8)}…)`,
213 + value: i.id
214 + }))
215 +)
216 +
217 +const appOptions = computed(() =>
218 + apps.value.map(a => ({
219 + label: a.name,
220 + value: a.id
221 + }))
222 +)
223 +
224 +async function loadIntegrations() {
225 + loadingIntegrations.value = true
226 + try {
227 + const res = await Api.notifications.listShuffleIntegrations(props.customerCode)
228 + if (res.data.success) {
229 + integrations.value = res.data.integrations
230 + }
231 + } catch (err: unknown) {
232 + message.error(getApiErrorMessage(err as never) || "Failed to load Shuffle integrations")
233 + } finally {
234 + loadingIntegrations.value = false
235 + }
236 +}
237 +
238 +async function loadApps(integrationId: number) {
239 + loadingApps.value = true
240 + appsError.value = null
241 + try {
242 + const res = await Api.notifications.listShuffleApps(props.customerCode, integrationId)
243 + if (res.data.success) {
244 + apps.value = res.data.apps
245 + } else {
246 + apps.value = []
247 + appsError.value = res.data.message || "Failed to load apps"
248 + }
249 + } catch (err: unknown) {
250 + apps.value = []
251 + appsError.value = getApiErrorMessage(err as never) || "Failed to load apps"
252 + } finally {
253 + loadingApps.value = false
254 + }
255 +}
256 +
257 +function onChannelChange(value: NotificationChannel) {
258 + // Clear channel-specific fields when switching, so we don't carry
259 + // stale Shuffle picks into an SMTP route or vice versa.
260 + if (value === "smtp_email") {
261 + form.shuffle_integration_id = null
262 + form.shuffle_app_id = null
263 + form.shuffle_app_name = null
264 + } else {
265 + // Reset destination — SMTP recipients don't make sense as a
266 + // Shuffle channel hint.
267 + if (!editing.value) form.destination = ""
268 + }
269 +}
270 +
271 +async function onIntegrationChange(integrationId: number | null) {
272 + apps.value = []
273 + form.shuffle_app_id = null
274 + form.shuffle_app_name = null
275 + if (integrationId) {
276 + await loadApps(integrationId)
277 + }
278 +}
279 +
280 +function onAppChange(appId: string | null) {
281 + // Cache the app's display name alongside the UUID so the UI list can
282 + // render "Slack" instead of a UUID without re-fetching the catalog
283 + // every render.
284 + const app = apps.value.find(a => a.id === appId)
285 + form.shuffle_app_name = app?.name ?? null
286 +}
287 +
288 +const rules: FormRules = {
289 + name: { required: true, message: "Name is required", trigger: ["input", "blur"] },
290 + trigger: { required: true, message: "Pick a trigger", trigger: ["change", "blur"] },
291 + channel: { required: true, message: "Pick a channel", trigger: ["change", "blur"] },
292 + min_severity: { required: true, message: "Pick a severity threshold", trigger: ["change", "blur"] },
293 + destination: {
294 + required: true,
295 + validator: (_rule, value: string) => {
296 + if (!value || !value.trim()) {
297 + return form.channel === "smtp_email"
298 + ? new Error("At least one recipient email required")
299 + : new Error("Destination hint is required")
300 + }
301 + if (form.channel === "smtp_email") {
302 + const recipients = value
303 + .split(",")
304 + .map(s => s.trim())
305 + .filter(Boolean)
306 + if (!recipients.length) return new Error("At least one recipient email required")
307 + const bad = recipients.find(r => !r.includes("@"))
308 + if (bad) return new Error(`Invalid email: ${bad}`)
309 + }
310 + return true
311 + },
312 + trigger: ["input", "blur"]
313 + },
314 + shuffle_integration_id: {
315 + validator: (_rule, value: number | null) => {
316 + if (form.channel === "shuffle" && !value) {
317 + return new Error("Pick a Shuffle integration")
318 + }
319 + return true
320 + },
321 + trigger: ["change", "blur"]
322 + },
323 + shuffle_app_id: {
324 + validator: (_rule, value: string | null) => {
325 + if (form.channel === "shuffle" && !value) {
326 + return new Error("Pick a Shuffle app")
327 + }
328 + return true
329 + },
330 + trigger: ["change", "blur"]
331 + }
332 +}
333 +
334 +async function submit() {
335 + try {
336 + await formRef.value?.validate()
337 + } catch {
338 + return
339 + }
340 +
341 + submitting.value = true
342 + try {
343 + const payload: NotificationRoutePayload = {
344 + ...form,
345 + format_template: form.format_template?.trim() || null
346 + }
347 +
348 + const res = props.editingRoute
349 + ? await Api.notifications.updateRoute(props.customerCode, props.editingRoute.id, payload)
350 + : await Api.notifications.createRoute(props.customerCode, payload)
351 +
352 + if (res.data.success) {
353 + message.success(editing.value ? "Route updated" : "Route created")
354 + emit("submitted")
355 + } else {
356 + message.warning(res.data.message || "Failed to save route")
357 + }
358 + } catch (err: unknown) {
359 + message.error(getApiErrorMessage(err as never) || "Failed to save route")
360 + } finally {
361 + submitting.value = false
362 + }
363 +}
364 +
365 +onBeforeMount(async () => {
366 + await loadIntegrations()
367 + // If editing a Shuffle route, prefetch the apps for its integration
368 + // so the picker is populated when the form first renders.
369 + if (props.editingRoute?.shuffle_integration_id) {
370 + await loadApps(props.editingRoute.shuffle_integration_id)
371 + }
372 +})
373 +</script>
frontend/src/components/customers/aiNotifications/CustomerAiNotificationRouteItem.vue new
+184
@@ -0,0 +1,184 @@
1 +<template>
2 + <CardEntity hoverable embedded>
3 + <template #headerMain>
4 + <div class="flex items-center gap-2">
5 + <Icon :name="channelIcon" :size="16" />
6 + <span class="font-medium">{{ route.name }}</span>
7 + <Badge v-if="!route.enabled" type="splitted" color="warning">
8 + <template #label>Status</template>
9 + <template #value>Disabled</template>
10 + </Badge>
11 + </div>
12 + </template>
13 +
14 + <template #headerExtra>
15 + <div class="flex items-center gap-2">
16 + <n-tooltip>
17 + <template #trigger>
18 + <n-button size="tiny" quaternary circle @click="toggleEnabled">
19 + <template #icon>
20 + <Icon :name="route.enabled ? PauseIcon : PlayIcon" :size="14" />
21 + </template>
22 + </n-button>
23 + </template>
24 + {{ route.enabled ? "Disable" : "Enable" }}
25 + </n-tooltip>
26 +
27 + <n-tooltip>
28 + <template #trigger>
29 + <n-button size="tiny" quaternary circle @click="$emit('edit')">
30 + <template #icon>
31 + <Icon :name="EditIcon" :size="14" />
32 + </template>
33 + </n-button>
34 + </template>
35 + Edit
36 + </n-tooltip>
37 +
38 + <n-popconfirm @positive-click="confirmDelete">
39 + <template #trigger>
40 + <n-button size="tiny" quaternary circle>
41 + <template #icon>
42 + <Icon :name="DeleteIcon" :size="14" />
43 + </template>
44 + </n-button>
45 + </template>
46 + Delete this route? Dispatch log entries will be retained.
47 + </n-popconfirm>
48 + </div>
49 + </template>
50 +
51 + <template #default>
52 + <div class="flex flex-col gap-2 text-sm">
53 + <div class="flex flex-wrap items-center gap-2">
54 + <Badge type="splitted" bright>
55 + <template #label>Trigger</template>
56 + <template #value>{{ triggerLabel }}</template>
57 + </Badge>
58 + <Badge type="splitted" :color="severityColor">
59 + <template #label>Min severity</template>
60 + <template #value>{{ route.min_severity }}</template>
61 + </Badge>
62 + <Badge type="splitted">
63 + <template #label>Channel</template>
64 + <template #value>{{ channelLabel }}</template>
65 + </Badge>
66 + </div>
67 + <div class="text-secondary">
68 + <span class="font-medium">Destination:</span>
69 + <code class="ml-1 break-all">{{ destinationDisplay }}</code>
70 + </div>
71 + <div v-if="route.format_template" class="text-secondary">
72 + <span class="font-medium">Custom template:</span>
73 + <span class="ml-1 italic">configured</span>
74 + </div>
75 + </div>
76 + </template>
77 +
78 + <template #footer>
79 + <div class="text-tertiary flex items-center gap-3 text-xs">
80 + <span>{{ route.dispatch_count }} dispatch(es)</span>
81 + <span>·</span>
82 + <span v-if="route.last_dispatched_at">
83 + last fired {{ formatDate(route.last_dispatched_at, "MMM D, YYYY HH:mm") }}
84 + </span>
85 + <span v-else>never fired</span>
86 + <span v-if="route.created_by">· created by {{ route.created_by }}</span>
87 + </div>
88 + </template>
89 + </CardEntity>
90 +</template>
91 +
92 +<script setup lang="ts">
93 +import type { NotificationRoute } from "@/types/notifications.d"
94 +import { NButton, NPopconfirm, NTooltip, useMessage } from "naive-ui"
95 +import { computed } from "vue"
96 +import Api from "@/api"
97 +import Badge from "@/components/common/Badge.vue"
98 +import CardEntity from "@/components/common/cards/CardEntity.vue"
99 +import Icon from "@/components/common/Icon.vue"
100 +import { getApiErrorMessage } from "@/utils"
101 +import { formatDate } from "@/utils/format"
102 +
103 +const props = defineProps<{
104 + route: NotificationRoute
105 +}>()
106 +
107 +const emit = defineEmits<{
108 + (e: "edit"): void
109 + (e: "deleted"): void
110 + (e: "toggled"): void
111 +}>()
112 +
113 +const EditIcon = "carbon:edit"
114 +const DeleteIcon = "carbon:trash-can"
115 +const PauseIcon = "carbon:pause"
116 +const PlayIcon = "carbon:play"
117 +
118 +const message = useMessage()
119 +
120 +// Channel icon + label. Shuffle routes show the underlying app name
121 +// (cached on the route row at form-submit time, so we don't have to
122 +// roundtrip to Shuffle on every list render).
123 +const channelIcon = computed(() => {
124 + if (props.route.channel === "shuffle") return "carbon:integration"
125 + return "carbon:email"
126 +})
127 +const channelLabel = computed(() => {
128 + if (props.route.channel === "shuffle") {
129 + return props.route.shuffle_app_name
130 + ? `Shuffle · ${props.route.shuffle_app_name}`
131 + : "Shuffle"
132 + }
133 + return "SMTP email"
134 +})
135 +
136 +const triggerLabel = computed(() =>
137 + props.route.trigger === "investigation_complete"
138 + ? "Every investigation"
139 + : "Critical / High only"
140 +)
141 +
142 +const severityColor = computed<"danger" | "warning" | "success">(() => {
143 + if (props.route.min_severity === "Critical" || props.route.min_severity === "High") return "danger"
144 + if (props.route.min_severity === "Medium") return "warning"
145 + return "success"
146 +})
147 +
148 +// SMTP recipients shown verbatim — email addresses aren't secrets the
149 +// way Slack webhook URLs were. Phase 2 may reintroduce per-channel
150 +// formatting logic for shuffle integrations.
151 +const destinationDisplay = computed(() => props.route.destination)
152 +
153 +async function toggleEnabled() {
154 + try {
155 + const res = await Api.notifications.updateRoute(
156 + props.route.customer_code,
157 + props.route.id,
158 + { enabled: !props.route.enabled }
159 + )
160 + if (res.data.success) {
161 + message.success(`Route ${res.data.route.enabled ? "enabled" : "disabled"}`)
162 + emit("toggled")
163 + } else {
164 + message.warning(res.data.message || "Failed to toggle route")
165 + }
166 + } catch (err: unknown) {
167 + message.error(getApiErrorMessage(err as never) || "Failed to toggle route")
168 + }
169 +}
170 +
171 +async function confirmDelete() {
172 + try {
173 + const res = await Api.notifications.deleteRoute(props.route.customer_code, props.route.id)
174 + if (res.data.success) {
175 + message.success("Route deleted")
176 + emit("deleted")
177 + } else {
178 + message.warning(res.data.message || "Failed to delete route")
179 + }
180 + } catch (err: unknown) {
181 + message.error(getApiErrorMessage(err as never) || "Failed to delete route")
182 + }
183 +}
184 +</script>
frontend/src/components/customers/aiNotifications/CustomerAiNotificationRoutes.vue new
+118
@@ -0,0 +1,118 @@
1 +<template>
2 + <div class="customer-ai-notification-routes">
3 + <transition name="form-fade" mode="out-in">
4 + <div v-if="showForm">
5 + <CustomerAiNotificationRouteForm
6 + :customer-code
7 + :editing-route
8 + @submitted="onFormSubmitted"
9 + @close="closeForm()"
10 + />
11 + </div>
12 + <div v-else>
13 + <div class="flex items-center justify-between gap-4 px-7 pt-2">
14 + <n-button size="small" type="primary" @click="openForm()">
15 + <template #icon>
16 + <Icon :name="AddIcon" :size="14" />
17 + </template>
18 + Add route
19 + </n-button>
20 + <n-button size="small" :disabled="loading" @click="refreshList()">
21 + <template #icon>
22 + <Icon :name="RefreshIcon" :size="14" />
23 + </template>
24 + Refresh
25 + </n-button>
26 + </div>
27 +
28 + <n-spin :show="loading">
29 + <div class="min-h-52 p-7 pt-4">
30 + <template v-if="list.length">
31 + <CustomerAiNotificationRouteItem
32 + v-for="route of list"
33 + :key="route.id"
34 + :route="route"
35 + class="item-appear item-appear-bottom item-appear-005 mb-2"
36 + @edit="openEdit(route)"
37 + @deleted="refreshList()"
38 + @toggled="refreshList()"
39 + />
40 + </template>
41 + <template v-else>
42 + <n-empty
43 + v-if="!loading"
44 + description="No routes configured. Add one to send Talon's investigation summaries to Slack or email."
45 + class="h-48 justify-center"
46 + />
47 + </template>
48 + </div>
49 + </n-spin>
50 + </div>
51 + </transition>
52 + </div>
53 +</template>
54 +
55 +<script setup lang="ts">
56 +import type { NotificationRoute } from "@/types/notifications.d"
57 +import { NButton, NEmpty, NSpin, useMessage } from "naive-ui"
58 +import { onBeforeMount, ref } from "vue"
59 +import Api from "@/api"
60 +import Icon from "@/components/common/Icon.vue"
61 +import { getApiErrorMessage } from "@/utils"
62 +import CustomerAiNotificationRouteForm from "./CustomerAiNotificationRouteForm.vue"
63 +import CustomerAiNotificationRouteItem from "./CustomerAiNotificationRouteItem.vue"
64 +
65 +const { customerCode } = defineProps<{
66 + customerCode: string
67 +}>()
68 +
69 +const AddIcon = "carbon:add-alt"
70 +const RefreshIcon = "carbon:renew"
71 +
72 +const message = useMessage()
73 +const showForm = ref(false)
74 +const loading = ref(false)
75 +const list = ref<NotificationRoute[]>([])
76 +const editingRoute = ref<NotificationRoute | null>(null)
77 +
78 +function refreshList() {
79 + loading.value = true
80 + Api.notifications
81 + .listRoutes(customerCode)
82 + .then(res => {
83 + if (res.data.success) {
84 + list.value = res.data.routes
85 + } else {
86 + message.warning(res.data.message || "Failed to load routes")
87 + }
88 + })
89 + .catch(err => {
90 + message.error(getApiErrorMessage(err) || "Failed to load routes")
91 + })
92 + .finally(() => {
93 + loading.value = false
94 + })
95 +}
96 +
97 +function openForm() {
98 + editingRoute.value = null
99 + showForm.value = true
100 +}
101 +
102 +function openEdit(route: NotificationRoute) {
103 + editingRoute.value = route
104 + showForm.value = true
105 +}
106 +
107 +function closeForm() {
108 + showForm.value = false
109 + editingRoute.value = null
110 +}
111 +
112 +function onFormSubmitted() {
113 + closeForm()
114 + refreshList()
115 +}
116 +
117 +onBeforeMount(refreshList)
118 +</script>
frontend/src/components/customers/aiNotifications/CustomerAiNotifications.vue new
+32
@@ -0,0 +1,32 @@
1 +<template>
2 + <div class="customer-ai-notifications">
3 + <n-tabs type="line" animated :tabs-padding="24">
4 + <n-tab-pane name="routes" tab="Routes" display-directive="show:lazy">
5 + <CustomerAiNotificationRoutes :customer-code="customerCode" />
6 + </n-tab-pane>
7 + <n-tab-pane name="shuffle_integrations" tab="Shuffle integrations" display-directive="show:lazy">
8 + <CustomerShuffleIntegrations :customer-code="customerCode" />
9 + </n-tab-pane>
10 + <n-tab-pane name="dispatch_log" tab="Dispatch log" display-directive="show:lazy">
11 + <CustomerAiNotificationDispatchLog :customer-code="customerCode" />
12 + </n-tab-pane>
13 + </n-tabs>
14 + </div>
15 +</template>
16 +
17 +<script setup lang="ts">
18 +// Container for the per-customer "AI Notifications" tab. Three
19 +// sub-tabs:
20 +// - Routes: CRUD list + form for notification rules
21 +// - Shuffle integrations: CRUD for the customer's Shuffle Org-Id rows
22 +// used by 'shuffle'-channel routes
23 +// - Dispatch log: read-only audit trail of every dispatch attempt
24 +import { NTabPane, NTabs } from "naive-ui"
25 +import CustomerAiNotificationDispatchLog from "./CustomerAiNotificationDispatchLog.vue"
26 +import CustomerAiNotificationRoutes from "./CustomerAiNotificationRoutes.vue"
27 +import CustomerShuffleIntegrations from "./CustomerShuffleIntegrations.vue"
28 +
29 +defineProps<{
30 + customerCode: string
31 +}>()
32 +</script>
frontend/src/components/customers/aiNotifications/CustomerShuffleIntegrationForm.vue new
+129
@@ -0,0 +1,129 @@
1 +<template>
2 + <n-form
3 + ref="formRef"
4 + :model="form"
5 + :rules="rules"
6 + label-placement="top"
7 + class="px-7 py-4"
8 + >
9 + <div class="mb-3 flex items-center justify-between">
10 + <h3 class="text-lg font-medium">
11 + {{ editing ? "Edit Shuffle integration" : "Add Shuffle integration" }}
12 + </h3>
13 + <n-button size="small" quaternary @click="$emit('close')">
14 + <template #icon>
15 + <Icon :name="CloseIcon" :size="14" />
16 + </template>
17 + Cancel
18 + </n-button>
19 + </div>
20 +
21 + <n-form-item label="Display name" path="display_name">
22 + <n-input
23 + v-model:value="form.display_name"
24 + placeholder="e.g. Acme Production Shuffle"
25 + :maxlength="128"
26 + show-count
27 + />
28 + </n-form-item>
29 +
30 + <n-form-item label="Shuffle Org-Id" path="shuffle_org_id">
31 + <n-input
32 + v-model:value="form.shuffle_org_id"
33 + placeholder="6b6f65a4-d8f8-48ef-b02f-23a4a5f73e4a"
34 + :maxlength="64"
35 + />
36 + <template #feedback>
37 + <span class="text-tertiary text-xs">
38 + Find this on the customer's Shuffle org settings page. Sent as the
39 + <code>Org-Id</code> header on each dispatch — scopes the Shuffle call
40 + to the right org's authenticated apps.
41 + </span>
42 + </template>
43 + </n-form-item>
44 +
45 + <n-form-item>
46 + <n-checkbox v-model:checked="form.enabled">Enabled</n-checkbox>
47 + </n-form-item>
48 +
49 + <div class="flex justify-end gap-2">
50 + <n-button @click="$emit('close')">Cancel</n-button>
51 + <n-button type="primary" :loading="submitting" @click="submit">
52 + {{ editing ? "Save changes" : "Add integration" }}
53 + </n-button>
54 + </div>
55 + </n-form>
56 +</template>
57 +
58 +<script setup lang="ts">
59 +import type { ShuffleIntegration, ShuffleIntegrationPayload } from "@/types/notifications.d"
60 +import type { FormInst, FormRules } from "naive-ui"
61 +import { NButton, NCheckbox, NForm, NFormItem, NInput, useMessage } from "naive-ui"
62 +import { computed, reactive, ref } from "vue"
63 +import Api from "@/api"
64 +import Icon from "@/components/common/Icon.vue"
65 +import { getApiErrorMessage } from "@/utils"
66 +
67 +const props = defineProps<{
68 + customerCode: string
69 + editingIntegration: ShuffleIntegration | null
70 +}>()
71 +
72 +const emit = defineEmits<{
73 + (e: "submitted"): void
74 + (e: "close"): void
75 +}>()
76 +
77 +const CloseIcon = "carbon:close"
78 +
79 +const message = useMessage()
80 +const formRef = ref<FormInst | null>(null)
81 +const submitting = ref(false)
82 +
83 +const editing = computed(() => props.editingIntegration !== null)
84 +
85 +const form = reactive<ShuffleIntegrationPayload>({
86 + display_name: props.editingIntegration?.display_name ?? "",
87 + shuffle_org_id: props.editingIntegration?.shuffle_org_id ?? "",
88 + enabled: props.editingIntegration?.enabled ?? true
89 +})
90 +
91 +const rules: FormRules = {
92 + display_name: { required: true, message: "Name is required", trigger: ["input", "blur"] },
93 + shuffle_org_id: {
94 + required: true,
95 + message: "Shuffle Org-Id is required",
96 + trigger: ["input", "blur"]
97 + }
98 +}
99 +
100 +async function submit() {
101 + try {
102 + await formRef.value?.validate()
103 + } catch {
104 + return
105 + }
106 +
107 + submitting.value = true
108 + try {
109 + const res = props.editingIntegration
110 + ? await Api.notifications.updateShuffleIntegration(
111 + props.customerCode,
112 + props.editingIntegration.id,
113 + form
114 + )
115 + : await Api.notifications.createShuffleIntegration(props.customerCode, form)
116 +
117 + if (res.data.success) {
118 + message.success(editing.value ? "Integration updated" : "Integration added")
119 + emit("submitted")
120 + } else {
121 + message.warning(res.data.message || "Failed to save integration")
122 + }
123 + } catch (err: unknown) {
124 + message.error(getApiErrorMessage(err as never) || "Failed to save integration")
125 + } finally {
126 + submitting.value = false
127 + }
128 +}
129 +</script>
frontend/src/components/customers/aiNotifications/CustomerShuffleIntegrationItem.vue new
+225
@@ -0,0 +1,225 @@
1 +<template>
2 + <CardEntity hoverable embedded>
3 + <template #headerMain>
4 + <div class="flex items-center gap-2">
5 + <Icon name="carbon:integration" :size="16" />
6 + <span class="font-medium">{{ integration.display_name }}</span>
7 + <Badge v-if="!integration.enabled" type="splitted" color="warning">
8 + <template #label>Status</template>
9 + <template #value>Disabled</template>
10 + </Badge>
11 + <Badge v-if="verifyResult === 'ok'" type="splitted" color="success">
12 + <template #label>Probe</template>
13 + <template #value>{{ verifyAppCount }} app(s)</template>
14 + </Badge>
15 + <Badge v-else-if="verifyResult === 'fail'" type="splitted" color="danger">
16 + <template #label>Probe</template>
17 + <template #value>failed</template>
18 + </Badge>
19 + </div>
20 + </template>
21 +
22 + <template #headerExtra>
23 + <div class="flex items-center gap-2">
24 + <n-tooltip>
25 + <template #trigger>
26 + <n-button
27 + size="tiny"
28 + quaternary
29 + circle
30 + :loading="verifying"
31 + :type="verifyButtonType"
32 + @click="verify"
33 + >
34 + <template #icon>
35 + <Icon :name="verifyButtonIcon" :size="14" />
36 + </template>
37 + </n-button>
38 + </template>
39 + {{ verifyTooltip }}
40 + </n-tooltip>
41 +
42 + <n-tooltip>
43 + <template #trigger>
44 + <n-button size="tiny" quaternary circle @click="toggleEnabled">
45 + <template #icon>
46 + <Icon :name="integration.enabled ? PauseIcon : PlayIcon" :size="14" />
47 + </template>
48 + </n-button>
49 + </template>
50 + {{ integration.enabled ? "Disable" : "Enable" }}
51 + </n-tooltip>
52 +
53 + <n-tooltip>
54 + <template #trigger>
55 + <n-button size="tiny" quaternary circle @click="$emit('edit')">
56 + <template #icon>
57 + <Icon :name="EditIcon" :size="14" />
58 + </template>
59 + </n-button>
60 + </template>
61 + Edit
62 + </n-tooltip>
63 +
64 + <n-popconfirm @positive-click="confirmDelete">
65 + <template #trigger>
66 + <n-button size="tiny" quaternary circle>
67 + <template #icon>
68 + <Icon :name="DeleteIcon" :size="14" />
69 + </template>
70 + </n-button>
71 + </template>
72 + Delete this integration?
73 + <template #icon>
74 + <Icon :name="WarningIcon" :size="14" />
75 + </template>
76 + </n-popconfirm>
77 + </div>
78 + </template>
79 +
80 + <template #default>
81 + <div class="flex flex-col gap-1 text-sm">
82 + <div class="text-secondary">
83 + <span class="font-medium">Org-Id:</span>
84 + <code class="ml-1 break-all">{{ integration.shuffle_org_id }}</code>
85 + </div>
86 + <div v-if="verifyError" class="text-error text-xs">
87 + {{ verifyError }}
88 + </div>
89 + </div>
90 + </template>
91 +
92 + <template #footer>
93 + <div class="text-tertiary flex items-center gap-3 text-xs">
94 + <span v-if="integration.last_used_at">
95 + last used {{ formatDate(integration.last_used_at, "MMM D, YYYY HH:mm") }}
96 + </span>
97 + <span v-else>never used</span>
98 + <span v-if="integration.created_by">· created by {{ integration.created_by }}</span>
99 + </div>
100 + </template>
101 + </CardEntity>
102 +</template>
103 +
104 +<script setup lang="ts">
105 +import type { ShuffleIntegration } from "@/types/notifications.d"
106 +import { NButton, NPopconfirm, NTooltip, useMessage } from "naive-ui"
107 +import { computed, ref } from "vue"
108 +import Api from "@/api"
109 +import Badge from "@/components/common/Badge.vue"
110 +import CardEntity from "@/components/common/cards/CardEntity.vue"
111 +import Icon from "@/components/common/Icon.vue"
112 +import { getApiErrorMessage } from "@/utils"
113 +import { formatDate } from "@/utils/format"
114 +
115 +const props = defineProps<{
116 + integration: ShuffleIntegration
117 +}>()
118 +
119 +const emit = defineEmits<{
120 + (e: "edit"): void
121 + (e: "deleted"): void
122 + (e: "toggled"): void
123 +}>()
124 +
125 +const EditIcon = "carbon:edit"
126 +const DeleteIcon = "carbon:trash-can"
127 +const PauseIcon = "carbon:pause"
128 +const PlayIcon = "carbon:play"
129 +const VerifyIcon = "carbon:checkmark-outline"
130 +const VerifyOkIcon = "carbon:checkmark-filled"
131 +const VerifyFailIcon = "carbon:misuse"
132 +const WarningIcon = "carbon:warning"
133 +
134 +const message = useMessage()
135 +
136 +// Verify state — null until the user clicks "Test connection." Drives
137 +// both the inline status badge and the verify button's color/icon so
138 +// admins get an at-a-glance signal that the probe succeeded without
139 +// having to read the badge text.
140 +const verifying = ref(false)
141 +const verifyResult = ref<"ok" | "fail" | null>(null)
142 +const verifyAppCount = ref<number | null>(null)
143 +const verifyError = ref<string | null>(null)
144 +
145 +// naive-ui n-button accepts type='success'|'error'|'default'|... — we
146 +// map verify result to a colored button so the icon turns green on a
147 +// good probe and red on a bad one, and stays neutral before/while
148 +// running. `quaternary` keeps the button visually subtle when there's
149 +// no result yet.
150 +const verifyButtonType = computed<"default" | "success" | "error">(() => {
151 + if (verifyResult.value === "ok") return "success"
152 + if (verifyResult.value === "fail") return "error"
153 + return "default"
154 +})
155 +
156 +const verifyButtonIcon = computed(() => {
157 + if (verifyResult.value === "ok") return VerifyOkIcon
158 + if (verifyResult.value === "fail") return VerifyFailIcon
159 + return VerifyIcon
160 +})
161 +
162 +const verifyTooltip = computed(() => {
163 + if (verifyResult.value === "ok") return `Probe OK · ${verifyAppCount.value ?? "?"} app(s) · click to re-test`
164 + if (verifyResult.value === "fail") return "Probe failed · click to re-test"
165 + return "Test connection"
166 +})
167 +
168 +async function verify() {
169 + verifying.value = true
170 + verifyError.value = null
171 + try {
172 + const res = await Api.notifications.verifyShuffleIntegration(
173 + props.integration.customer_code,
174 + props.integration.id
175 + )
176 + if (res.data.success) {
177 + verifyResult.value = "ok"
178 + verifyAppCount.value = res.data.app_count
179 + } else {
180 + verifyResult.value = "fail"
181 + verifyError.value = res.data.error || res.data.message || "Verification failed"
182 + }
183 + } catch (err: unknown) {
184 + verifyResult.value = "fail"
185 + verifyError.value = getApiErrorMessage(err as never) || "Verification failed"
186 + } finally {
187 + verifying.value = false
188 + }
189 +}
190 +
191 +async function toggleEnabled() {
192 + try {
193 + const res = await Api.notifications.updateShuffleIntegration(
194 + props.integration.customer_code,
195 + props.integration.id,
196 + { enabled: !props.integration.enabled }
197 + )
198 + if (res.data.success) {
199 + message.success(`Integration ${res.data.integration.enabled ? "enabled" : "disabled"}`)
200 + emit("toggled")
201 + } else {
202 + message.warning(res.data.message || "Failed to toggle integration")
203 + }
204 + } catch (err: unknown) {
205 + message.error(getApiErrorMessage(err as never) || "Failed to toggle integration")
206 + }
207 +}
208 +
209 +async function confirmDelete() {
210 + try {
211 + const res = await Api.notifications.deleteShuffleIntegration(
212 + props.integration.customer_code,
213 + props.integration.id
214 + )
215 + if (res.data.success) {
216 + message.success("Integration deleted")
217 + emit("deleted")
218 + } else {
219 + message.warning(res.data.message || "Failed to delete integration")
220 + }
221 + } catch (err: unknown) {
222 + message.error(getApiErrorMessage(err as never) || "Failed to delete integration")
223 + }
224 +}
225 +</script>
frontend/src/components/customers/aiNotifications/CustomerShuffleIntegrations.vue new
+125
@@ -0,0 +1,125 @@
1 +<template>
2 + <div class="customer-shuffle-integrations">
3 + <transition name="form-fade" mode="out-in">
4 + <div v-if="showForm">
5 + <CustomerShuffleIntegrationForm
6 + :customer-code
7 + :editing-integration
8 + @submitted="onFormSubmitted"
9 + @close="closeForm()"
10 + />
11 + </div>
12 + <div v-else>
13 + <div class="flex items-center justify-between gap-4 px-7 pt-2">
14 + <n-button size="small" type="primary" @click="openForm()">
15 + <template #icon>
16 + <Icon :name="AddIcon" :size="14" />
17 + </template>
18 + Add Shuffle integration
19 + </n-button>
20 + <n-button size="small" :disabled="loading" @click="refreshList()">
21 + <template #icon>
22 + <Icon :name="RefreshIcon" :size="14" />
23 + </template>
24 + Refresh
25 + </n-button>
26 + </div>
27 +
28 + <div class="text-secondary px-7 pt-2 text-sm">
29 + Each row is one of this customer's Shuffle organizations. Routes set to the
30 + <strong>Shuffle</strong> channel reference one of these to pick the right org
31 + for outbound notifications. The deployment-wide Shuffle API key lives in the
32 + CoPilot connectors table — these rows just record the per-customer Org-Id.
33 + </div>
34 +
35 + <n-spin :show="loading">
36 + <div class="min-h-52 p-7 pt-4">
37 + <template v-if="list.length">
38 + <CustomerShuffleIntegrationItem
39 + v-for="integration of list"
40 + :key="integration.id"
41 + :integration="integration"
42 + class="item-appear item-appear-bottom item-appear-005 mb-2"
43 + @edit="openEdit(integration)"
44 + @deleted="refreshList()"
45 + @toggled="refreshList()"
46 + />
47 + </template>
48 + <template v-else>
49 + <n-empty
50 + v-if="!loading"
51 + description="No Shuffle integrations configured. Add one to enable Shuffle-channel notification routes for this customer."
52 + class="h-48 justify-center"
53 + />
54 + </template>
55 + </div>
56 + </n-spin>
57 + </div>
58 + </transition>
59 + </div>
60 +</template>
61 +
62 +<script setup lang="ts">
63 +import type { ShuffleIntegration } from "@/types/notifications.d"
64 +import { NButton, NEmpty, NSpin, useMessage } from "naive-ui"
65 +import { onBeforeMount, ref } from "vue"
66 +import Api from "@/api"
67 +import Icon from "@/components/common/Icon.vue"
68 +import { getApiErrorMessage } from "@/utils"
69 +import CustomerShuffleIntegrationForm from "./CustomerShuffleIntegrationForm.vue"
70 +import CustomerShuffleIntegrationItem from "./CustomerShuffleIntegrationItem.vue"
71 +
72 +const { customerCode } = defineProps<{
73 + customerCode: string
74 +}>()
75 +
76 +const AddIcon = "carbon:add-alt"
77 +const RefreshIcon = "carbon:renew"
78 +
79 +const message = useMessage()
80 +const showForm = ref(false)
81 +const loading = ref(false)
82 +const list = ref<ShuffleIntegration[]>([])
83 +const editingIntegration = ref<ShuffleIntegration | null>(null)
84 +
85 +function refreshList() {
86 + loading.value = true
87 + Api.notifications
88 + .listShuffleIntegrations(customerCode)
89 + .then(res => {
90 + if (res.data.success) {
91 + list.value = res.data.integrations
92 + } else {
93 + message.warning(res.data.message || "Failed to load Shuffle integrations")
94 + }
95 + })
96 + .catch(err => {
97 + message.error(getApiErrorMessage(err) || "Failed to load Shuffle integrations")
98 + })
99 + .finally(() => {
100 + loading.value = false
101 + })
102 +}
103 +
104 +function openForm() {
105 + editingIntegration.value = null
106 + showForm.value = true
107 +}
108 +
109 +function openEdit(integration: ShuffleIntegration) {
110 + editingIntegration.value = integration
111 + showForm.value = true
112 +}
113 +
114 +function closeForm() {
115 + showForm.value = false
116 + editingIntegration.value = null
117 +}
118 +
119 +function onFormSubmitted() {
120 + closeForm()
121 + refreshList()
122 +}
123 +
124 +onBeforeMount(refreshList)
125 +</script>
frontend/src/types/notifications.d.ts new
+98
@@ -0,0 +1,98 @@
1 +// Notification routing — types mirror app/notifications/schema/notifications.py.
2 +// Kept intentionally narrow: Phase 1 ships Slack webhook + SMTP email only;
3 +// Phase 2 will extend the channel union with 'shuffle'.
4 +
5 +export type NotificationTrigger = "investigation_complete" | "severity_critical_or_high"
6 +
7 +export type NotificationChannel = "smtp_email" | "shuffle"
8 +
9 +export type NotificationSeverity = "Critical" | "High" | "Medium" | "Low" | "Informational"
10 +
11 +export type DispatchStatus = "sent" | "failed" | "skipped"
12 +
13 +export interface NotificationRoute {
14 + id: number
15 + customer_code: string
16 + name: string
17 + trigger: NotificationTrigger
18 + channel: NotificationChannel
19 + destination: string
20 + min_severity: NotificationSeverity
21 + format_template: string | null
22 + enabled: boolean
23 + last_dispatched_at: string | null
24 + dispatch_count: number
25 + created_by: string | null
26 + created_at: string
27 + updated_at: string | null
28 + // Phase 2 — populated only when channel === "shuffle"
29 + shuffle_integration_id: number | null
30 + shuffle_app_id: string | null
31 + shuffle_app_name: string | null
32 +}
33 +
34 +export interface NotificationRoutePayload {
35 + name: string
36 + trigger: NotificationTrigger
37 + channel: NotificationChannel
38 + destination: string
39 + min_severity: NotificationSeverity
40 + format_template?: string | null
41 + enabled: boolean
42 + shuffle_integration_id?: number | null
43 + shuffle_app_id?: string | null
44 + shuffle_app_name?: string | null
45 +}
46 +
47 +export type NotificationRouteUpdatePayload = Partial<NotificationRoutePayload>
48 +
49 +export interface NotificationDispatchLogEntry {
50 + id: number
51 + customer_code: string
52 + alert_id: number
53 + route_id: number
54 + trigger: string
55 + dispatched_at: string
56 + status: DispatchStatus
57 + error_message: string | null
58 + latency_ms: number | null
59 + payload_preview: string | null
60 + shuffle_execution_id: string | null
61 +}
62 +
63 +// ----- Shuffle integrations (Phase 2) -----
64 +
65 +export interface ShuffleIntegration {
66 + id: number
67 + customer_code: string
68 + display_name: string
69 + shuffle_org_id: string
70 + enabled: boolean
71 + last_used_at: string | null
72 + created_by: string | null
73 + created_at: string
74 + updated_at: string | null
75 +}
76 +
77 +export interface ShuffleIntegrationPayload {
78 + display_name: string
79 + shuffle_org_id: string
80 + enabled: boolean
81 +}
82 +
83 +export type ShuffleIntegrationUpdatePayload = Partial<ShuffleIntegrationPayload>
84 +
85 +export interface ShuffleApp {
86 + id: string
87 + name: string
88 + description: string | null
89 + large_image: string | null
90 +}
91 +
92 +export interface ShuffleVerifyResult {
93 + success: boolean
94 + message: string
95 + org_id: string
96 + app_count: number | null
97 + error: string | null
98 +}