From 5f4ec9d769933bd87a1520feec5ce689d143c901 Mon Sep 17 00:00:00 2001 From: Jbelieve Date: Sat, 5 Sep 2026 02:54:01 -0500 Subject: [PATCH 1/2] Rebrand to BeReply, redirect root to auth, add prod compose - Believe Blue accent + BrandMark wordmark (Be cyan, Reply blue, cyan dot) in sidebar, login, verify-request, invite pages - Sidebar recolored to Believe Blue 950 with negative wordmark variant - Root page now redirects to /dashboard or /login instead of the OSS marketing landing - Favicon/app icons generated natively via next/og ImageResponse, replacing static PNGs - docker-compose.prod.yml for self-hosted deploy (web+worker+cron+ postgres+redis behind Coolify/Traefik), secrets sourced from .env Co-Authored-By: Claude Sonnet 5 --- app/apple-icon.tsx | 28 ++ app/globals.css | 4 +- app/icon-192/route.tsx | 25 ++ app/icon-512/route.tsx | 25 ++ app/icon.tsx | 28 ++ app/invite/[token]/page.tsx | 7 +- app/layout.tsx | 23 +- app/login/page.tsx | 7 +- app/manifest.ts | 14 +- app/page.tsx | 511 +--------------------------------- app/verify-request/page.tsx | 7 +- components/brand-mark.tsx | 36 +++ components/follower-chart.tsx | 2 +- components/sidebar.tsx | 19 +- docker-compose.prod.yml | 99 +++++++ lib/auth.ts | 2 +- public/apple-touch-icon.png | Bin 7093 -> 0 bytes public/icon-192.png | Bin 7578 -> 0 bytes public/icon-512.png | Bin 20641 -> 0 bytes 19 files changed, 290 insertions(+), 547 deletions(-) create mode 100644 app/apple-icon.tsx create mode 100644 app/icon-192/route.tsx create mode 100644 app/icon-512/route.tsx create mode 100644 app/icon.tsx create mode 100644 components/brand-mark.tsx create mode 100644 docker-compose.prod.yml delete mode 100644 public/apple-touch-icon.png delete mode 100644 public/icon-192.png delete mode 100644 public/icon-512.png diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx new file mode 100644 index 00000000..2c6bd9e9 --- /dev/null +++ b/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ B +
+ ), + size + ); +} diff --git a/app/globals.css b/app/globals.css index ffd70164..fe34d3fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -11,8 +11,8 @@ --color-border: #e4e4e7; --color-border-hover: #d4d4d8; --color-muted: #71717a; - --color-accent: #f97316; - --color-accent-hover: #ea580c; + --color-accent: #0c3bb9; + --color-accent-hover: #062778; --color-success: #16a34a; --color-error: #dc2626; --color-warning: #d97706; diff --git a/app/icon-192/route.tsx b/app/icon-192/route.tsx new file mode 100644 index 00000000..abe5f286 --- /dev/null +++ b/app/icon-192/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; + +export function GET() { + return new ImageResponse( + ( +
+ B +
+ ), + { width: 192, height: 192 } + ); +} diff --git a/app/icon-512/route.tsx b/app/icon-512/route.tsx new file mode 100644 index 00000000..3d0c4657 --- /dev/null +++ b/app/icon-512/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; + +export function GET() { + return new ImageResponse( + ( +
+ B +
+ ), + { width: 512, height: 512 } + ); +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 00000000..9d5f5d5e --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 32, height: 32 }; +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+ B +
+ ), + size + ); +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 78dd9b62..3ad5ddf5 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -4,13 +4,14 @@ import { notFound } from "next/navigation"; import InvitationAcceptCard from "@/components/invitation-accept-card"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db/client"; +import { BrandMark } from "@/components/brand-mark"; type InvitePageProps = { params: Promise<{ token: string }>; }; export const metadata: Metadata = { - title: "Accept Workspace Invitation - OpenReply", + title: "Accept Workspace Invitation - BeReply", robots: { index: false, follow: false }, }; @@ -35,8 +36,8 @@ export default async function InvitePage({ params }: InvitePageProps) { return (
- - OpenReply + +

diff --git a/app/layout.tsx b/app/layout.tsx index 07a39858..2e09b1f1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,31 +1,30 @@ import type { Metadata, Viewport } from "next"; +import { Fraunces } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; +const fraunces = Fraunces({ + subsets: ["latin"], + weight: "500", + variable: "--font-display", +}); + export const metadata: Metadata = { - title: "OpenReply - Open source Instagram comment-to-DM automation", + title: "BeReply - Instagram comment-to-DM automation", description: - "A free, self-hosted ManyChat alternative. Send an Instagram DM automatically when someone comments a keyword on your post or reel, using the official Meta API.", + "Send an Instagram DM automatically when someone comments a keyword on your post or reel, using the official Meta API.", keywords: [ "instagram automation", "comment to DM", "instagram private replies", "social commerce", - "manychat alternative", ], manifest: "/manifest.webmanifest", appleWebApp: { capable: true, - title: "OpenReply", + title: "BeReply", statusBarStyle: "black-translucent", }, - icons: { - icon: [ - { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: "/apple-touch-icon.png", - }, }; export const viewport: Viewport = { @@ -43,7 +42,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +

-

- OpenReply +

+

{selectedTemplate diff --git a/app/manifest.ts b/app/manifest.ts index 31556a60..f7eb7a5c 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -6,18 +6,18 @@ import type { MetadataRoute } from "next"; // a phone practical. export default function manifest(): MetadataRoute.Manifest { return { - name: "OpenReply", - short_name: "OpenReply", + name: "BeReply", + short_name: "BeReply", description: "Instagram comment-to-DM automation", start_url: "/overview", display: "standalone", orientation: "portrait", - background_color: "#18181b", - theme_color: "#18181b", + background_color: "#050C29", + theme_color: "#0c3bb9", icons: [ - { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, - { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, - { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + { src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "maskable" }, ], }; } diff --git a/app/page.tsx b/app/page.tsx index 2276552a..73bcb0cd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,508 +1,7 @@ -import type { Metadata } from "next"; -import type { ReactNode } from "react"; -import Link from "next/link"; -import { DemoNotice } from "@/components/demo-notice"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; -export const metadata: Metadata = { - title: "OpenReply - Open source Instagram comment-to-DM automation", - description: - "A free, self-hosted ManyChat alternative. Turn Instagram keyword comments into automatic private replies using the official Meta API.", -}; - -const GITHUB_URL = "https://github.com/diwenne/openreply"; -const SETUP_DOCS_URL = - "https://github.com/diwenne/openreply/blob/main/docs/setup.md"; - -function formatStars(count: number): string { - if (count >= 1000) { - return `${(count / 1000).toFixed(1)}K`; - } - return count.toLocaleString(); -} - -const githubIconPath = - "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"; - -const heroStats = [ - { value: "24/7", label: "Comment monitoring" }, - { value: "1", label: "DM per matched comment" }, - { value: "0", label: "Scraping required" }, -]; - -const flowSteps = [ - { - eyebrow: "Connect", - title: "Link your Instagram professional account", - description: - "Sign in by email and connect Instagram once. No password sharing, no browser automation.", - }, - { - eyebrow: "Build", - title: "Pick a post, keywords, and the DM", - description: - "Create a campaign for a reel or post: the keyword to watch, the public reply, and the DM to send.", - }, - { - eyebrow: "Deliver", - title: "Replies go out through the official API", - description: - "Webhooks catch comments instantly and a polling sweep catches the ones Instagram never pushes, so nothing is missed. Every send is queued, rate-limited, and logged.", - }, -]; - -const features = [ - "Email magic-link sign-in", - "Multiple Instagram accounts", - "Encrypted tokens at rest", - "Webhook + polling reconciliation", - "Queue-backed delivery worker", - "Per-account rate limiting", - "Tracked links with click stats", - "DM logs with full status", - "No plan limits, fully self-hosted", -]; - -/* Static, faithful copies of the real Overview and Dashboard screens, built in - the app's own design tokens so what visitors see is what the app looks like. */ - -function AppWindow({ label, children }: { label: string; children: ReactNode }) { - return ( -

-
- - - - {label} -
-
{children}
-
- ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -const overviewStats = [ - ["Views", "847.2K"], - ["Reach", "612.4K"], - ["Likes", "38.1K"], - ["Comments", "4,204"], - ["Saved", "9,712"], - ["Shares", "2,340"], -]; - -const overviewPosts = [ - ["Spring drop reel", "214.8K", "9.1K", "Apr 3"], - ["Restock haul", "88.4K", "5.2K", "Mar 28"], - ["Behind the studio", "51.3K", "3.4K", "Mar 21"], -]; - -function OverviewPreview() { - return ( - -
-
-

Overview

-

- Recent — 24 posts from @studio.store -

-
- - Last 50 - -
- -
- {overviewStats.map(([label, value]) => ( - - ))} -
- -
-
-

- Followers over time -

-

- 48,210 +1,240 · 30d -

-
- -
- -
-

Posts

- - - - - - - - - - - {overviewPosts.map(([post, views, likes, date]) => ( - - - - - - - ))} - -
PostViewsLikesDate
{post}{views}{likes}{date}
-
-
- ); -} - -function MatchedCommentCard() { - return ( -
-

New comment

-

@maya.co

-

LINK please

-
-

- Matched GUIDE -

-

- Queued private reply -

-
-
- ); -} - -const dashboardStats = [ - ["Active Campaigns", "8"], - ["DMs Sent", "1,284"], - ["Skipped", "42"], - ["Failed", "3"], - ["Clicks", "356"], - ["CTR", "27.7%"], -]; - -const dashboardChart: [string, number][] = [ - ["Mon", 42], - ["Tue", 68], - ["Wed", 51], - ["Thu", 94], - ["Fri", 120], - ["Sat", 86], - ["Sun", 73], -]; - -const dashboardActivity = [ - ["@maya.co", "Product guide reply", "Sent", "text-success"], - ["@founder.ray", "Price request", "Sent", "text-success"], - ["@shop.ava", "Lead magnet", "Queued", "text-warning"], -]; - -function DashboardPreview() { - const maxDM = Math.max(...dashboardChart.map(([, n]) => n)); - return ( - -

Hello, Maya!

-

2 connected accounts · 340 contacts

- -
- {dashboardStats.map(([label, value]) => ( - - ))} -
- -
-

DMs — Last 7 Days

-
- {dashboardChart.map(([day, n]) => ( -
- {n} -
- {day} -
- ))} -
-
- -
-

Recent Activity

-
- {dashboardActivity.map(([user, automation, status, color]) => ( -
- {user} - {automation} - {status} -
- ))} -
-
- - ); -} - -async function getGitHubStars(): Promise { - try { - const res = await fetch("https://api.github.com/repos/diwenne/openreply", { - headers: { Accept: "application/vnd.github+json" }, - next: { revalidate: 3600 }, - }); - if (!res.ok) return null; - const data = (await res.json()) as { stargazers_count?: number }; - return typeof data.stargazers_count === "number" ? data.stargazers_count : null; - } catch { - return null; - } -} - -export default async function Home() { - const stars = await getGitHubStars(); - return ( -
- - -
-
- - OpenReply - - - -
-
- -
-
-
- Open source · Official Meta API -
- -

- Make every comment start the right DM -

- -

- Open-sourced ManyChat. When someone comments your keyword on a post - or reel, they get your DM a second later. Free, self-hosted, and - built on the official Instagram API. -

- -
- - Get started - - - See how it works - -
- -
- {heroStats.map((stat) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
-
- -
- -
- -
-
-
- -
-
-
-

How it works

-

- A comment in, a DM out -

-

- Three steps. Connect an account, build a campaign, and let it run. - The webhook handles it live and the poll sweeps up whatever the - webhook misses. -

-
- -
- {flowSteps.map((step) => ( -
-

{step.eyebrow}

-
-

{step.title}

-

{step.description}

-
-
- ))} -
-
-
- -
-
- - -
-

The dashboard

-

- See exactly what happened -

-

- Every comment event is traceable: queued, matched, sent, skipped, - failed, or rate-limited. No black box. -

-
-
-
- -
-
-

What’s included

-

- Everything, no tiers -

-

- It is self-hosted and open source, so there is nothing to unlock. You - run it, you own it. -

-
- -
- {features.map((feature) => ( -
- {feature} -
- ))} -
-
- -
-
-
-

- Turn your next reel’s comments into DMs -

-

- Free and open source. Star it if it saves you a subscription. -

-

- - On your own deployment, not this one. - {" "} - Clone the repo and follow the{" "} - - setup guide - {" "} - — a Meta app and a domain of your own are required before anything - sends. -

-
-
- - Get started - - - View on GitHub - -
-
-
- - -
- ); +export default async function RootPage() { + const session = await auth(); + redirect(session?.user?.id ? "/dashboard" : "/login"); } diff --git a/app/verify-request/page.tsx b/app/verify-request/page.tsx index 8606c32a..c4d3e1a7 100644 --- a/app/verify-request/page.tsx +++ b/app/verify-request/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; +import { BrandMark } from "@/components/brand-mark"; export const metadata = { - title: "Check your email - OpenReply", + title: "Check your email - BeReply", description: "A sign-in link was sent to your email.", }; @@ -10,8 +11,8 @@ export default function VerifyRequestPage() {
-

- OpenReply +

+

diff --git a/components/brand-mark.tsx b/components/brand-mark.tsx new file mode 100644 index 00000000..af1f53dc --- /dev/null +++ b/components/brand-mark.tsx @@ -0,0 +1,36 @@ +/** + * BeReply wordmark — Believe brand system §3: "Be" prefix in Cyan 400, + * descriptor in Believe Blue 700 (Paper 50 in the negative variant, for + * dark/blue surfaces), set in Fraunces 500. Closing dot is Cyan 400, + * the same signature device that closes the house "Believe." wordmark. + */ +export function BrandMark({ + className = "", + variant = "positive", +}: { + className?: string; + variant?: "positive" | "negative"; +}) { + const descriptorColor = variant === "negative" ? "#fafaf7" : "#0c3bb9"; + return ( + + Be + Reply + + ); +} diff --git a/components/follower-chart.tsx b/components/follower-chart.tsx index 8d4af5f1..95e20885 100644 --- a/components/follower-chart.tsx +++ b/components/follower-chart.tsx @@ -31,7 +31,7 @@ export interface FollowerChartPoint { // Colors read against the light chart surface (#ffffff): the accent line clears // 3:1 contrast and grid/axis text match the muted/border tokens. See globals.css. -const SERIES_COLOR = "#f97316"; +const SERIES_COLOR = "#0c3bb9"; const GRID_COLOR = "#e4e4e7"; const AXIS_TEXT = "#71717a"; diff --git a/components/sidebar.tsx b/components/sidebar.tsx index bf7129b4..0227a91e 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { BrandMark } from "@/components/brand-mark"; const navItems = [ { label: "Dashboard", href: "/dashboard" }, @@ -44,7 +45,7 @@ export default function Sidebar({
@@ -75,8 +76,8 @@ export default function Sidebar({ block px-3 py-2.5 rounded text-sm ${ isActive - ? "bg-surface-hover text-foreground font-medium" - : "text-muted hover:text-foreground hover:bg-surface-hover" + ? "bg-white/10 text-white font-medium" + : "text-white/60 hover:text-white hover:bg-white/5" } `} > @@ -86,9 +87,9 @@ export default function Sidebar({ })} -
-

{workspaceName}

-

Self-hosted

+
+

{workspaceName}

+

Self-hosted

diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..dbf3cea3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,99 @@ +# OpenReply para Contabo — Traefik labels apuntan al network `coolify` (gestionado por Coolify) +# Coolify Traefik sirve SSL Let's Encrypt automático al ver estos labels. + +services: + openreply-postgres: + image: postgres:16 + container_name: openreply-postgres + restart: always + environment: + POSTGRES_DB: openreply + POSTGRES_USER: openreply + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} + volumes: + - openreply-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U openreply"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - openreply-internal + + openreply-redis: + image: redis:7-alpine + container_name: openreply-redis + restart: always + volumes: + - openreply-redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - openreply-internal + + openreply-web: + build: . + container_name: openreply-web + restart: always + env_file: .env + command: sh -c "npm run db:migrate && npm start" + depends_on: + openreply-postgres: + condition: service_healthy + openreply-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/api/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - openreply-internal + - coolify + labels: + - "traefik.enable=true" + - "traefik.http.routers.openreply.rule=Host(`reply.believe-global.com`)" + - "traefik.http.routers.openreply.entrypoints=https" + - "traefik.http.routers.openreply.tls=true" + - "traefik.http.routers.openreply.tls.certresolver=letsencrypt" + - "traefik.http.services.openreply.loadbalancer.server.port=3000" + + openreply-worker: + build: . + container_name: openreply-worker + restart: always + env_file: .env + command: npm run worker + depends_on: + openreply-postgres: + condition: service_healthy + openreply-redis: + condition: service_healthy + networks: + - openreply-internal + + openreply-cron: + build: . + container_name: openreply-cron + restart: always + env_file: .env + command: sh scripts/cron.sh + depends_on: + openreply-web: + condition: service_healthy + networks: + - openreply-internal + +volumes: + openreply-pgdata: + openreply-redisdata: + +networks: + openreply-internal: + driver: bridge + coolify: + external: true diff --git a/lib/auth.ts b/lib/auth.ts index 2b69660b..6ad7777f 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -8,7 +8,7 @@ import { isEmailAllowedToSignIn } from "@/lib/env"; type AdapterPrismaClient = Parameters[0]; -const emailFrom = process.env.EMAIL_FROM ?? "OpenReply "; +const emailFrom = process.env.EMAIL_FROM ?? "BeReply "; // Setting EMAIL_SERVER switches magic links to your own SMTP server, for // self-hosters who do not want a third-party mail service. Resend stays the // default, so an existing deployment is unaffected. diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index 40ec0456b125d9b13af887144ff66b9443756029..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7093 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

s6yMH3#qkP=-|&Ze5YE(<( zqre7G=NdnDb)oz_cZSs3%FCRM0vkXb%Kz9FpRT>GOyzP&&CRU*jRYIOVU+*D2R&NW zQ`YAJd4sF*U;{YB?(V&7UApP!GTC-Ug2lanNEno{ zo*yeaTekT0jq9WQoV>x6O{7wyUugZ$Mz$J!ON25YTful#~grn zce8q^$sj>umsCnUBZP_Gzu^ra{_=?F!O~|ooIYk4l$2QiQQJ*LYD>fWK0{!|&o)Ak z64HboJT47s{5cGK{E{szV6u(&gXqtag32R7P_9UcGfxbY1%%;wC1oh_kbL5aBcD3nIq?F?57p9?6N+~IAQp(>UMDVCww@&y%(H{)M!r$2dn1)6O3QaSI|Ng0`XI3$C zdxX3@`m^GB0MpPYg%u#2@sPj5@7e%NVgA~6`YTeJdP{h^E6FgEg%HAU-I59@xC_6_ zt}Z24tZ05tk-E>cnvn65-5{n>2~4v|1cAHprWG?cVMAEPqkstMJ5tE#5-Wyj&DcT2;r-UH4sjRaPIq~W^zD+Zb0#WQo~zABK| z4_>%Xnb%%Bb03kq&9a)H{(P?q(@;RCEvr>175uEHr)gV9hcddmMX24M^UjNwKMf(i z>3Stk2t>*-OhX~V(8Y@9*A99T|G*Mp5j{O^Ta@qpIG1acO5vak!!!aaulxYmGwiDpo;}`JT=FJD<-Ubbzw|7y{ z(Lr+c4XvBJz_}$F1r(10Osat=W7}@y4tf9HJxCdDg1Ci#BZB}0B19u9Q8_7w*i0OkT19+oX@c|nE#=PauUqW~KKlS<&h z-W;8*^0^$qQh!^44&*S==h*q@Fy%IIArg3A`~yE~U}ztC~^1wusUCK)CjsKByX z5cd41d2?G|yLmII-d=b!V&oAZX8xlXCX}EsTyexFDgjh{P$Njr+j%jepxa=EzVrd2a~7B2j? zxo+M0{&24yal;GB-_@nen^qlhr&HPc<$S))R7Asm2*V@=ManDW+bpM2TnXie4v(b# z;BdXU=<3p2#{T7<#oP1wwngPi(Gw)}=^2Je5=w=BK0m{Boc?>CeEgxe9Cg%r!}|TT z94_QRAy^JeqkVJR-tRaA_vCZ!7OZUItIsgwQ~q3ThUxj`hkPe^*SdA*1taZ}0Kt*a!-F z9toJ~2i^$W9?vU1>8Xf6b&-?3+`izVKK}5%wy~@tFInF>Yi~#Gc zN`-uWfSHElEy{-udS3bNQva?CW93I!PraWq>LpDWL2O-@FDYJQTdkk<{Sww(d9#d} zB9zuO+ioS--QV-XmOp+JD?PQkSubN;(^n$)u`au!|GlPZE|*4rhU*l4A&B=1m`MVi zJ_WYjVuqnt2?Otwt9sgQi1cx(=553fRD8{|3Tf574#ghI{3Tbv3X1?8Z zil`7O#xX?9IKUi}L@w7ZgP>9g!tg7`A0PX2&qJ^FaFiE`-Ny6j6B5<{-)reoQaEY< zfzEv=<)pQgNc^*D7RYsr7`H+pkb#23D42TCNf|<9F|_SAoa4oTmOI^`+;wx$tc`fZ zSp5Crn9#VMgGPgolwMCHt|q14W?3)CP5zw1--+zn%aXfAm1Cnm&JrxC^cvh zOlEs}g7;vWW)mTAkL#66g5*y_MayqoQCJ)66@E@cuV2fgDc;ZuFTQGEp7iM5ifB<7 zhG*yUEfV$~1Ql)Rk(-1c$X8i0|G}a1^I&c$_Fp#C$H1h=K*mFzx!^1+UJ4|WZ zzI=J%sp`ALqD3@#h<&1&bOjqaB7h}JD)U1M-lRxfK%^EZrRG^yvy2ZfhaoQPz=IE? zH3C9NeJ2-=Cq_4t5@GL%i2f>s+@OTGheUYqs%0%3;`hMSL86AyY-)|EZZt)7b#+Oi zrPWMy2mH6Iu2OBT>zx)TZvjblCMkNEQsD_ibOe!Vo2vRTG)d^;ykC*nO+?#?=>MdU w8zhN8NmfR@S<>zzDw31rs353>B_nZF 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 Date: Sat, 5 Sep 2026 04:13:41 -0500 Subject: [PATCH 2/2] Add AI auto-responder, flow builder, contacts/tags, ecommerce webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four modules built in parallel (Sonnet: AI/contacts/webhook, Opus: flow builder), then wired together into the existing DM worker without touching the plain keyword->dmMessage path for campaigns that use neither feature. - lib/ai/responder.ts: Claude-backed auto-responder, tool-use structured output ({reply, escalate, reason}), deterministic opt-out regex ahead of the model call, 15s hard timeout, never throws. - lib/flow/engine.ts + components/flow-builder/*: visual trigger->action canvas (@xyflow/react, ported from Maasy's WhatsAppFlowBuilder pattern) for condition/send_dm/add_tag/ai_reply nodes. Pure engine, no I/O. - lib/contacts.ts + app/(dashboard)/contacts: per-workspace Contact model with freeform tags, populated as messages come in. - app/api/webhooks/ecommerce: HMAC-verified inbound webhook for BeCommerce/Medusa order events, mirroring the existing pushOrderToMaasyCrm subscriber pattern. Logs only for now — no Contact matching, since there is no real link between a BeCommerce order email and an Instagram igUserId yet. - dm-worker.ts: aiEnabled/flow campaigns resolve a smart reply after the follow gate clears; escalation releases the DM reservation and logs SKIPPED_ESCALATED (new DmStatus value) instead of guessing. - Migration is additive only: new nullable columns, one new table, one new enum value. Verified against a fresh local Postgres via migrate diff + migrate deploy before touching production. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 --- app/(dashboard)/campaigns/[id]/flow/page.tsx | 54 +++ app/(dashboard)/contacts/page.tsx | 57 +++ app/(dashboard)/settings/page.tsx | 99 +++- app/api/automations/[id]/flow/route.ts | 149 ++++++ app/api/automations/route.ts | 21 +- app/api/contacts/[id]/tags/route.ts | 78 ++++ app/api/contacts/route.ts | 36 ++ app/api/settings/ecommerce/route.ts | 93 ++++ app/api/webhooks/ecommerce/route.ts | 121 +++++ components/campaign-ai-settings.tsx | 95 ++++ components/campaign-builder.tsx | 31 ++ components/contact-tags.tsx | 106 +++++ components/flow-builder/flow-builder.tsx | 410 +++++++++++++++++ components/flow-builder/flow-node.tsx | 183 ++++++++ components/sidebar.tsx | 1 + components/top-bar.tsx | 1 + lib/ai/responder.ts | 152 ++++++ lib/contacts.ts | 65 +++ lib/flow/engine.test.ts | 143 ++++++ lib/flow/engine.ts | 207 +++++++++ lib/queue/dm-worker.ts | 227 ++++++++- package-lock.json | 434 +++++++++++++++++- package.json | 2 + .../migration.sql | 43 ++ prisma/schema.prisma | 41 ++ vitest.config.ts | 2 +- 26 files changed, 2821 insertions(+), 30 deletions(-) create mode 100644 app/(dashboard)/campaigns/[id]/flow/page.tsx create mode 100644 app/(dashboard)/contacts/page.tsx create mode 100644 app/api/automations/[id]/flow/route.ts create mode 100644 app/api/contacts/[id]/tags/route.ts create mode 100644 app/api/contacts/route.ts create mode 100644 app/api/settings/ecommerce/route.ts create mode 100644 app/api/webhooks/ecommerce/route.ts create mode 100644 components/campaign-ai-settings.tsx create mode 100644 components/contact-tags.tsx create mode 100644 components/flow-builder/flow-builder.tsx create mode 100644 components/flow-builder/flow-node.tsx create mode 100644 lib/ai/responder.ts create mode 100644 lib/contacts.ts create mode 100644 lib/flow/engine.test.ts create mode 100644 lib/flow/engine.ts create mode 100644 prisma/migrations/20260905091136_add_ai_flow_contacts_ecommerce/migration.sql diff --git a/app/(dashboard)/campaigns/[id]/flow/page.tsx b/app/(dashboard)/campaigns/[id]/flow/page.tsx new file mode 100644 index 00000000..e007b4c7 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/flow/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { AutomationFlowEditor } from "@/components/flow-builder/flow-builder"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; +import { parseFlowEdges, parseFlowNodes } from "@/lib/flow/engine"; + +type FlowPageProps = { params: Promise<{ id: string }> }; + +export default async function CampaignFlowPage({ params }: FlowPageProps) { + const { id } = await params; + + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + redirect("/login"); + } + + const automation = await prisma.automation.findFirst({ + where: { id, workspaceId }, + select: { id: true, name: true, nodes: true, edges: true }, + }); + + if (!automation) { + notFound(); + } + + const nodes = parseFlowNodes(automation.nodes); + const edges = parseFlowEdges(automation.edges); + + return ( +

+
+ + ← {automation.name} + +

Flow

+

+ {nodes.length === 0 + ? "This campaign runs on its keyword and DM settings. Build a flow here to take over from them." + : "This flow replaces the campaign's keyword and DM settings. Save an empty canvas to go back to them."} +

+
+ + +
+ ); +} diff --git a/app/(dashboard)/contacts/page.tsx b/app/(dashboard)/contacts/page.tsx new file mode 100644 index 00000000..1911a468 --- /dev/null +++ b/app/(dashboard)/contacts/page.tsx @@ -0,0 +1,57 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; +import { ensureWorkspaceForUser } from "@/lib/workspace"; +import ContactTags from "@/components/contact-tags"; + +const PAGE_SIZE = 50; + +export default async function ContactsPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const workspace = await ensureWorkspaceForUser( + session.user.id, + session.user.email + ); + + const contacts = await prisma.contact.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { updatedAt: "desc" }, + take: PAGE_SIZE, + }); + + if (contacts.length === 0) { + return ( +
+

No contacts yet

+

+ Todavía no hay contactos — aparecen automáticamente cuando alguien + te escribe o comenta. +

+
+ ); + } + + return ( +
+

+ {contacts.length} contact{contacts.length !== 1 ? "s" : ""} +

+
+ {contacts.map((contact) => ( + + ))} +
+
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index d523e55b..cf8207be 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -45,11 +45,20 @@ interface WorkspaceMembersData { }>; } +interface EcommerceSettings { + slug: string | null; + hasSecret: boolean; + webhookUrl: string; +} + export default function SettingsPage() { const [data, setData] = useState(null); const [membersData, setMembersData] = useState( null ); + const [ecommerce, setEcommerce] = useState(null); + const [ecommerceSlugInput, setEcommerceSlugInput] = useState(""); + const [revealedSecret, setRevealedSecret] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [inviteEmail, setInviteEmail] = useState(""); @@ -60,14 +69,43 @@ export default function SettingsPage() { Promise.all([ fetch("/api/dashboard/stats").then((res) => res.json()), fetch("/api/workspace/members").then((res) => res.json()), + fetch("/api/settings/ecommerce").then((res) => res.json()), ]) - .then(([statsPayload, membersPayload]) => { + .then(([statsPayload, membersPayload, ecommercePayload]) => { if (statsPayload.success) setData(statsPayload.data); if (membersPayload.success) setMembersData(membersPayload.data); + if (ecommercePayload.success) { + setEcommerce(ecommercePayload.data); + setEcommerceSlugInput(ecommercePayload.data.slug ?? ""); + } }) .finally(() => setLoading(false)); }, []); + async function generateEcommerceSecret() { + setBusy("ecommerce"); + setRevealedSecret(null); + const res = await fetch("/api/settings/ecommerce", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + ecommerceSlugInput.trim() ? { slug: ecommerceSlugInput.trim() } : {} + ), + }); + const payload = await res.json(); + setBusy(null); + if (payload.success) { + setRevealedSecret(payload.data.secret); + setEcommerce((prev) => + prev + ? { ...prev, hasSecret: true, slug: ecommerceSlugInput.trim() || prev.slug } + : prev + ); + } else { + alert(payload.error ?? "Could not generate secret"); + } + } + async function refreshMembers() { const res = await fetch("/api/workspace/members"); const payload = await res.json(); @@ -336,6 +374,65 @@ export default function SettingsPage() {
+ +
+

Ecommerce integration

+

+ Connect a BeCommerce (Medusa) store to log its order events here. +

+ +
+
+ + setEcommerceSlugInput(e.target.value)} + placeholder="your-store-slug" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none" + /> +
+ +
+ + +
+ + + + {revealedSecret && ( +
+

+ Copy this now — it won't be shown again. Set it as + BEREPLY_WEBHOOK_SECRET on the BeCommerce side. +

+ + {revealedSecret} + +
+ )} + + {!revealedSecret && ecommerce?.hasSecret && ( +

+ A secret is already set. Regenerating replaces it immediately. +

+ )} +
+
); } diff --git a/app/api/automations/[id]/flow/route.ts b/app/api/automations/[id]/flow/route.ts new file mode 100644 index 00000000..bdf7f4f9 --- /dev/null +++ b/app/api/automations/[id]/flow/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@/app/generated/prisma/client"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; +import { + FLOW_NODE_TYPES, + parseFlowEdges, + parseFlowNodes, +} from "@/lib/flow/engine"; +import { + canManageWorkspace, + getCurrentWorkspaceContext, +} from "@/lib/workspace-access"; + +// The builder reads back what it just wrote, so never cache this. +export const dynamic = "force-dynamic"; + +type RouteProps = { params: Promise<{ id: string }> }; + +const flowNodeSchema = z.object({ + id: z.string().min(1).max(64), + type: z.enum(FLOW_NODE_TYPES), + data: z.record(z.string(), z.unknown()), + position: z.object({ x: z.number(), y: z.number() }), +}); + +const flowEdgeSchema = z.object({ + id: z.string().min(1).max(128), + source: z.string().min(1).max(64), + sourceHandle: z.string().min(1).max(32).optional(), + target: z.string().min(1).max(64), +}); + +const flowSchema = z.object({ + nodes: z.array(flowNodeSchema).max(100), + edges: z.array(flowEdgeSchema).max(200), +}); + +export async function GET(_request: NextRequest, { params }: RouteProps) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const { id } = await params; + const automation = await prisma.automation.findFirst({ + where: { id, workspaceId }, + select: { nodes: true, edges: true }, + }); + + if (!automation) { + return NextResponse.json( + { success: false, error: "Campaign not found" }, + { status: 404 } + ); + } + + return NextResponse.json( + { + success: true, + data: { + nodes: parseFlowNodes(automation.nodes), + edges: parseFlowEdges(automation.edges), + }, + }, + { headers: { "Cache-Control": "no-store" } } + ); +} + +export async function PUT(request: NextRequest, { params }: RouteProps) { + const context = await getCurrentWorkspaceContext(); + if (!context) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + if (!canManageWorkspace(context.role)) { + return NextResponse.json( + { success: false, error: "Only owners and admins can edit campaigns" }, + { status: 403 } + ); + } + + const { id } = await params; + const body = await request.json(); + const parsed = flowSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: "Invalid input", + details: parsed.error.flatten(), + }, + { status: 400 } + ); + } + + const existing = await prisma.automation.findFirst({ + where: { id, workspaceId: context.workspaceId }, + select: { id: true }, + }); + + if (!existing) { + return NextResponse.json( + { success: false, error: "Campaign not found" }, + { status: 404 } + ); + } + + const nodeIds = new Set(parsed.data.nodes.map((node) => node.id)); + const danglingEdge = parsed.data.edges.find( + (edge) => !nodeIds.has(edge.source) || !nodeIds.has(edge.target) + ); + if (danglingEdge) { + return NextResponse.json( + { success: false, error: "An edge points at a node that is not in the flow" }, + { status: 400 } + ); + } + + // An empty canvas clears the flow instead of storing `[]`: a present-but-empty + // flow would leave the campaign doing nothing, while null keeps the worker on + // the legacy keyword/dmMessage path. + const isEmpty = parsed.data.nodes.length === 0; + + await prisma.automation.update({ + where: { id }, + data: { + nodes: isEmpty + ? Prisma.DbNull + : (parsed.data.nodes as unknown as Prisma.InputJsonValue), + edges: isEmpty + ? Prisma.DbNull + : (parsed.data.edges as unknown as Prisma.InputJsonValue), + }, + }); + + return NextResponse.json({ + success: true, + data: { nodes: parsed.data.nodes, edges: parsed.data.edges }, + }); +} diff --git a/app/api/automations/route.ts b/app/api/automations/route.ts index 0beea465..e09e2934 100644 --- a/app/api/automations/route.ts +++ b/app/api/automations/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCurrentWorkspaceId } from "@/lib/auth"; import { prisma } from "@/lib/db/client"; +import { Prisma } from "@/app/generated/prisma/client"; import { calculateCtr, normalizeTopKeywords } from "@/lib/tracking/analytics"; import { buildTrackedUrl } from "@/lib/tracking/message"; import { generateTrackedLinkSlug } from "@/lib/tracking/server"; @@ -60,6 +61,11 @@ const createAutomationSchema = z secondaryButtonLabel: z.string().max(20).optional().nullable(), isActive: z.boolean().optional().default(true), wholeWordMatch: z.boolean().optional().default(true), + aiEnabled: z.boolean().optional().default(false), + aiConfig: z + .object({ systemPrompt: z.string().max(4000) }) + .optional() + .nullable(), }) // A campaign must target a specific post, any post, or the next reel. .refine( @@ -106,6 +112,11 @@ const updateAutomationSchema = z.object({ publicReplyMessages: z.array(z.string().max(1000)).max(10).optional(), isActive: z.boolean().optional(), wholeWordMatch: z.boolean().optional(), + aiEnabled: z.boolean().optional(), + aiConfig: z + .object({ systemPrompt: z.string().max(4000) }) + .optional() + .nullable(), reportShareEnabled: z.boolean().optional(), // Empty string clears the tracked link; a URL updates/creates it; undefined // leaves it unchanged. @@ -427,6 +438,8 @@ export async function POST(request: NextRequest) { : null, isActive: parsed.data.isActive, wholeWordMatch: parsed.data.wholeWordMatch, + aiEnabled: parsed.data.aiEnabled, + aiConfig: parsed.data.aiConfig ?? undefined, workspaceId, instagramAccountId: instagramAccount.id, reportShareSlug: generateReportShareSlug(), @@ -538,7 +551,13 @@ export async function PATCH(request: NextRequest) { const updated = await prisma.automation.update({ where: { id: automationId }, - data: automationData, + data: { + ...automationData, + aiConfig: + automationData.aiConfig === null + ? Prisma.JsonNull + : automationData.aiConfig, + }, }); // Update, create, or clear the campaign's primary tracked link when a diff --git a/app/api/contacts/[id]/tags/route.ts b/app/api/contacts/[id]/tags/route.ts new file mode 100644 index 00000000..10382aac --- /dev/null +++ b/app/api/contacts/[id]/tags/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; +import { addTag, removeTag } from "@/lib/contacts"; + +type RouteProps = { params: Promise<{ id: string }> }; + +const tagSchema = z.object({ + tag: z.string().trim().min(1).max(50), +}); + +async function requireOwnedContact(workspaceId: string, contactId: string) { + return prisma.contact.findFirst({ + where: { id: contactId, workspaceId }, + select: { id: true }, + }); +} + +export async function POST(request: NextRequest, { params }: RouteProps) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const parsed = tagSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: "Invalid tag" }, + { status: 400 } + ); + } + + const { id } = await params; + const contact = await requireOwnedContact(workspaceId, id); + if (!contact) { + return NextResponse.json( + { success: false, error: "Contact not found" }, + { status: 404 } + ); + } + + const updated = await addTag(id, parsed.data.tag); + return NextResponse.json({ success: true, data: updated }); +} + +export async function DELETE(request: NextRequest, { params }: RouteProps) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const parsed = tagSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: "Invalid tag" }, + { status: 400 } + ); + } + + const { id } = await params; + const contact = await requireOwnedContact(workspaceId, id); + if (!contact) { + return NextResponse.json( + { success: false, error: "Contact not found" }, + { status: 404 } + ); + } + + const updated = await removeTag(id, parsed.data.tag); + return NextResponse.json({ success: true, data: updated }); +} diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts new file mode 100644 index 00000000..8a36581a --- /dev/null +++ b/app/api/contacts/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; + +// Tags are edited from this same list, so a stale cached read would show a +// contact's old tags right after the user just changed them. +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 50; + +export async function GET(request: NextRequest) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const cursor = request.nextUrl.searchParams.get("cursor"); + + const contacts = await prisma.contact.findMany({ + where: { workspaceId }, + orderBy: { updatedAt: "desc" }, + take: PAGE_SIZE, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + + const nextCursor = + contacts.length === PAGE_SIZE ? contacts[contacts.length - 1].id : null; + + return NextResponse.json({ + success: true, + data: { contacts, nextCursor }, + }); +} diff --git a/app/api/settings/ecommerce/route.ts b/app/api/settings/ecommerce/route.ts new file mode 100644 index 00000000..0b8d64d7 --- /dev/null +++ b/app/api/settings/ecommerce/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { + getCurrentWorkspaceContext, + canManageWorkspace, +} from "@/lib/workspace-access"; +import { prisma } from "@/lib/db/client"; +import { getBaseUrl } from "@/lib/env"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const context = await getCurrentWorkspaceContext(); + if (!context) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const workspace = await prisma.workspace.findUnique({ + where: { id: context.workspaceId }, + select: { ecommerceStoreSlug: true, ecommerceWebhookSecret: true }, + }); + + return NextResponse.json({ + success: true, + data: { + slug: workspace?.ecommerceStoreSlug ?? null, + hasSecret: Boolean(workspace?.ecommerceWebhookSecret), + webhookUrl: `${getBaseUrl()}/api/webhooks/ecommerce`, + }, + }); +} + +/** + * Sets the BeCommerce store slug and/or (re)generates the shared secret. + * The secret is returned exactly once, here — same pattern as an API key + * generator. Losing it means generating a new one, not recovering the old. + */ +export async function POST(request: NextRequest) { + const context = await getCurrentWorkspaceContext(); + if (!context) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + if (!canManageWorkspace(context.role)) { + return NextResponse.json( + { success: false, error: "Only owners and admins can change this" }, + { status: 403 } + ); + } + + const body = await request.json().catch(() => ({})); + const slug = + typeof body?.slug === "string" && body.slug.trim() + ? body.slug.trim() + : undefined; + + const secret = crypto.randomBytes(32).toString("hex"); + + try { + await prisma.workspace.update({ + where: { id: context.workspaceId }, + data: { + ...(slug ? { ecommerceStoreSlug: slug } : {}), + ecommerceWebhookSecret: secret, + }, + }); + } catch (error: unknown) { + // Unique constraint on ecommerceStoreSlug — another workspace already + // claimed it. + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return NextResponse.json( + { success: false, error: "That store slug is already in use" }, + { status: 409 } + ); + } + throw error; + } + + return NextResponse.json({ + success: true, + data: { secret, webhookUrl: `${getBaseUrl()}/api/webhooks/ecommerce` }, + }); +} diff --git a/app/api/webhooks/ecommerce/route.ts b/app/api/webhooks/ecommerce/route.ts new file mode 100644 index 00000000..782ab453 --- /dev/null +++ b/app/api/webhooks/ecommerce/route.ts @@ -0,0 +1,121 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db/client"; +import { Prisma } from "@/app/generated/prisma/client"; + +interface EcommerceOrderWebhookPayload { + event_type: string; + data: { + slug: string; + order_id: string; + display_id: number; + email: string; + name: string; + items: Array<{ title: string; quantity: number; unit_price: number }>; + total: number; + currency: string; + timestamp: string; + tags?: string[]; + }; +} + +function isEcommerceOrderWebhookPayload( + value: unknown +): value is EcommerceOrderWebhookPayload { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (typeof record.event_type !== "string") return false; + + const data = record.data; + if (typeof data !== "object" || data === null) return false; + + return typeof (data as Record).slug === "string"; +} + +// Same HMAC-over-raw-body pattern already proven in prod for the Maasy CRM +// integration. Compare hex digests as fixed-length buffers so a length +// mismatch short-circuits before timingSafeEqual (which throws on unequal +// lengths) instead of leaking timing information. +function isValidSignature( + rawBody: string, + signatureHeader: string | null, + secret: string +): boolean { + if (!signatureHeader) return false; + + const prefix = "sha256="; + if (!signatureHeader.startsWith(prefix)) return false; + const providedHex = signatureHeader.slice(prefix.length); + + const expectedHex = createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + + const provided = Buffer.from(providedHex, "utf8"); + const expected = Buffer.from(expectedHex, "utf8"); + if (provided.length !== expected.length) return false; + + return timingSafeEqual(provided, expected); +} + +export async function POST(request: NextRequest) { + try { + const rawBody = await request.text(); + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!isEcommerceOrderWebhookPayload(payload)) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const workspace = await prisma.workspace.findUnique({ + where: { ecommerceStoreSlug: payload.data.slug }, + select: { id: true, ecommerceWebhookSecret: true }, + }); + + if (!workspace) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + if (!workspace.ecommerceWebhookSecret) { + return NextResponse.json( + { error: "integration not configured" }, + { status: 400 } + ); + } + + const signatureHeader = request.headers.get("X-Webhook-Signature"); + if ( + !isValidSignature( + rawBody, + signatureHeader, + workspace.ecommerceWebhookSecret + ) + ) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + // Log-only by design: there is no way today to link a BeCommerce buyer + // email to an Instagram Contact (igUserId), so this intentionally does + // NOT attempt any matching/auto-tagging. It exists for audit trail and + // future features once that link exists. + await prisma.webhookEvent.create({ + data: { + workspaceId: workspace.id, + object: "ecommerce_order", + payload: payload as unknown as Prisma.InputJsonValue, + status: "PENDING", + }, + }); + + return NextResponse.json({ received: true }, { status: 200 }); + } catch (error) { + console.error("ecommerce webhook error", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/components/campaign-ai-settings.tsx b/components/campaign-ai-settings.tsx new file mode 100644 index 00000000..32d12659 --- /dev/null +++ b/components/campaign-ai-settings.tsx @@ -0,0 +1,95 @@ +"use client"; + +/** + * Campaign AI Settings + * + * Toggle + brand system prompt for the AI auto-responder (lib/ai/responder.ts). + * Controlled component — no fetch, no persistence. The caller owns state and + * saving; this just reports the next { aiEnabled, aiConfig } on every change. + */ + +interface AiConfig { + systemPrompt?: string; +} + +interface CampaignAiSettingsProps { + aiEnabled: boolean; + aiConfig: AiConfig | null; + onChange: (next: { + aiEnabled: boolean; + aiConfig: { systemPrompt: string } | null; + }) => void; +} + +function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { + return ( + + ); +} + +export default function CampaignAiSettings({ + aiEnabled, + aiConfig, + onChange, +}: CampaignAiSettingsProps) { + const systemPrompt = aiConfig?.systemPrompt ?? ""; + + return ( +
+

AI

+
+
+
+ Responder con IA +

+ Genera cada respuesta con IA en base a la conversación, en vez de + enviar siempre el mismo mensaje. +

+
+ + onChange({ + aiEnabled: !aiEnabled, + aiConfig: { systemPrompt }, + }) + } + /> +
+ {aiEnabled && ( +
+