shubhjn commited on
Commit
e140d17
·
1 Parent(s): e4885de

fix broken automation

Browse files
.cursorrules ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN: AgentMemoryBootstrap -->
2
+ ## Cursor Memory Bootstrap
3
+
4
+ When this workspace is loaded, initialize the shared memory context before
5
+ continuing:
6
+
7
+ 1. Run `python ../../.Agent/scripts/mem.py ctx --agent cursor --max-tokens 4000`.
8
+ 2. Load the returned `<MEMORY_CONTEXT>` into working context before proceeding.
9
+ 3. If there is an active in-progress task, continue it unless the user redirects.
10
+ 4. Save checkpoints with `python ../../.Agent/scripts/mem.py cp "what was done. next step" --agent cursor`.
11
+ 5. Finish with `python ../../.Agent/scripts/mem.py done "summary"` and `python ../../.Agent/scripts/mem.py log <input> <output> --agent cursor --model <model>` when token counts are available.
12
+
13
+ Reference: `../../.Agent/AGENT_MEMORY.md`
14
+ <!-- END: AgentMemoryBootstrap -->
.gemini/GEMINI.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN: AgentMemoryBootstrap -->
2
+ ## Antigravity Memory Bootstrap
3
+
4
+ When this workspace is loaded, initialize the shared memory context before
5
+ continuing:
6
+
7
+ 1. Run `python ../../.Agent/scripts/mem.py ctx --agent antigravity --max-tokens 4000`.
8
+ 2. Load the returned `<MEMORY_CONTEXT>` into working context before proceeding.
9
+ 3. If there is an active in-progress task, continue it unless the user redirects.
10
+ 4. Save checkpoints with `python ../../.Agent/scripts/mem.py cp "what was done. next step" --agent antigravity`.
11
+ 5. Finish with `python ../../.Agent/scripts/mem.py done "summary"` and `python ../../.Agent/scripts/mem.py log <input> <output> --agent antigravity --model <model>` when token counts are available.
12
+
13
+ Reference: `../../.Agent/AGENT_MEMORY.md`
14
+ <!-- END: AgentMemoryBootstrap -->
.github/copilot-instructions.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN: AgentMemoryBootstrap -->
2
+ ## Copilot Memory Bootstrap
3
+
4
+ When this workspace is loaded, initialize the shared memory context before
5
+ continuing:
6
+
7
+ 1. Run `python ../../.Agent/scripts/mem.py ctx --agent copilot --max-tokens 4000`.
8
+ 2. Load the returned `<MEMORY_CONTEXT>` into working context before proceeding.
9
+ 3. If there is an active in-progress task, continue it unless the user redirects.
10
+ 4. Save checkpoints with `python ../../.Agent/scripts/mem.py cp "what was done. next step" --agent copilot`.
11
+ 5. Finish with `python ../../.Agent/scripts/mem.py done "summary"` and `python ../../.Agent/scripts/mem.py log <input> <output> --agent copilot --model <model>` when token counts are available.
12
+
13
+ Reference: `../../.Agent/AGENT_MEMORY.md`
14
+ <!-- END: AgentMemoryBootstrap -->
.vscode/tasks.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "label": "Mem: Init Session",
6
+ "type": "shell",
7
+ "command": "python",
8
+ "args": [
9
+ "../../.Agent/scripts/mem.py",
10
+ "init"
11
+ ],
12
+ "presentation": {
13
+ "reveal": "silent",
14
+ "panel": "shared",
15
+ "clear": true
16
+ },
17
+ "runOptions": {
18
+ "runOn": "folderOpen",
19
+ "instanceLimit": 1
20
+ }
21
+ },
22
+ {
23
+ "label": "Mem: Load Context",
24
+ "type": "shell",
25
+ "command": "python",
26
+ "args": [
27
+ "../../.Agent/scripts/mem.py",
28
+ "ctx",
29
+ "--agent",
30
+ "${input:memAgent}"
31
+ ],
32
+ "presentation": {
33
+ "reveal": "always",
34
+ "panel": "shared",
35
+ "clear": true
36
+ }
37
+ },
38
+ {
39
+ "label": "Mem: Status",
40
+ "type": "shell",
41
+ "command": "python",
42
+ "args": [
43
+ "../../.Agent/scripts/mem.py",
44
+ "status"
45
+ ],
46
+ "presentation": {
47
+ "reveal": "always",
48
+ "panel": "shared",
49
+ "clear": true
50
+ }
51
+ }
52
+ ],
53
+ "inputs": [
54
+ {
55
+ "id": "memAgent",
56
+ "type": "pickString",
57
+ "description": "Which agent context should be loaded?",
58
+ "options": [
59
+ "codex",
60
+ "copilot",
61
+ "cursor",
62
+ "claude",
63
+ "antigravity",
64
+ "claw",
65
+ "tiny-claw",
66
+ "pico-claw",
67
+ "micro-claw",
68
+ "nano-claw",
69
+ "rtiny-claw"
70
+ ],
71
+ "default": "codex"
72
+ }
73
+ ]
74
+ }
.zed/settings.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tasks": [
3
+ {
4
+ "label": "Mem: Status",
5
+ "command": "python ../../.Agent/scripts/mem.py status"
6
+ },
7
+ {
8
+ "label": "Mem: Load Context",
9
+ "command": "python ../../.Agent/scripts/mem.py ctx --agent codex"
10
+ },
11
+ {
12
+ "label": "Mem: Init Session",
13
+ "command": "python ../../.Agent/scripts/mem.py init"
14
+ }
15
+ ]
16
+ }
AGENTS.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN: AgentMemoryBootstrap -->
2
+ ## Codex Memory Bootstrap
3
+
4
+ When this workspace is loaded, initialize the shared memory context before
5
+ continuing:
6
+
7
+ 1. Run `python ../../.Agent/scripts/mem.py ctx --agent codex --max-tokens 4000`.
8
+ 2. Load the returned `<MEMORY_CONTEXT>` into working context before proceeding.
9
+ 3. If there is an active in-progress task, continue it unless the user redirects.
10
+ 4. Save checkpoints with `python ../../.Agent/scripts/mem.py cp "what was done. next step" --agent codex`.
11
+ 5. Finish with `python ../../.Agent/scripts/mem.py done "summary"` and `python ../../.Agent/scripts/mem.py log <input> <output> --agent codex --model <model>` when token counts are available.
12
+
13
+ Reference: `../../.Agent/AGENT_MEMORY.md`
14
+ <!-- END: AgentMemoryBootstrap -->
CLAUDE.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN: AgentMemoryBootstrap -->
2
+ ## Claude Memory Bootstrap
3
+
4
+ When this workspace is loaded, initialize the shared memory context before
5
+ continuing:
6
+
7
+ 1. Run `python ../../.Agent/scripts/mem.py ctx --agent claude --max-tokens 4000`.
8
+ 2. Load the returned `<MEMORY_CONTEXT>` into working context before proceeding.
9
+ 3. If there is an active in-progress task, continue it unless the user redirects.
10
+ 4. Save checkpoints with `python ../../.Agent/scripts/mem.py cp "what was done. next step" --agent claude`.
11
+ 5. Finish with `python ../../.Agent/scripts/mem.py done "summary"` and `python ../../.Agent/scripts/mem.py log <input> <output> --agent claude --model <model>` when token counts are available.
12
+
13
+ Reference: `../../.Agent/AGENT_MEMORY.md`
14
+ <!-- END: AgentMemoryBootstrap -->
Dockerfile CHANGED
@@ -1,10 +1,11 @@
1
  FROM node:20-slim AS base
2
 
3
- # Install necessary system dependencies for Puppeteer AND Redis
4
  RUN apt-get update && apt-get install -y \
5
  chromium \
6
  git \
7
- redis-server \
 
8
  # Dependencies for Puppeteer
9
  gconf-service \
10
  libasound2 \
@@ -86,5 +87,5 @@ ENV PORT=7860
86
  EXPOSE 7860
87
 
88
 
89
- # Start Redis and the App
90
  CMD ["./start.sh"]
 
1
  FROM node:20-slim AS base
2
 
3
+ # Install necessary system dependencies for Puppeteer and a local Redis-compatible backend.
4
  RUN apt-get update && apt-get install -y \
5
  chromium \
6
  git \
7
+ valkey \
8
+ valkey-redis-compat \
9
  # Dependencies for Puppeteer
10
  gconf-service \
11
  libasound2 \
 
87
  EXPOSE 7860
88
 
89
 
90
+ # Start Valkey, the worker, and the app
91
  CMD ["./start.sh"]
app/api/auth/otp/send/route.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { redis } from "@/lib/redis";
3
  import { sendWhatsAppOTP } from "@/lib/whatsapp/client";
4
  import { db } from "@/db";
5
  import { users } from "@/db/schema";
@@ -32,6 +32,8 @@ export async function POST(req: NextRequest) {
32
  const code = Math.floor(100000 + Math.random() * 900000).toString();
33
 
34
  // Store in Redis (TTL 5 mins)
 
 
35
  if (!redis) {
36
  throw new Error("Redis client not initialized");
37
  }
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { getRedis } from "@/lib/redis";
3
  import { sendWhatsAppOTP } from "@/lib/whatsapp/client";
4
  import { db } from "@/db";
5
  import { users } from "@/db/schema";
 
32
  const code = Math.floor(100000 + Math.random() * 900000).toString();
33
 
34
  // Store in Redis (TTL 5 mins)
35
+ const redis = getRedis();
36
+
37
  if (!redis) {
38
  throw new Error("Redis client not initialized");
39
  }
app/api/health/route.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { NextResponse } from "next/server";
2
  import { db } from "@/db";
3
  import { sql } from "drizzle-orm";
4
- import { redis } from "@/lib/redis";
5
  import { Logger } from "@/lib/logger";
6
 
7
  export const dynamic = "force-dynamic";
@@ -36,6 +36,8 @@ export async function GET(): Promise<NextResponse<HealthCheck>> {
36
  // Check Redis
37
  try {
38
  const redisStart = performance.now();
 
 
39
  if (redis) {
40
  await redis.ping();
41
  const redisLatency = Math.round(performance.now() - redisStart);
 
1
  import { NextResponse } from "next/server";
2
  import { db } from "@/db";
3
  import { sql } from "drizzle-orm";
4
+ import { getRedis } from "@/lib/redis";
5
  import { Logger } from "@/lib/logger";
6
 
7
  export const dynamic = "force-dynamic";
 
36
  // Check Redis
37
  try {
38
  const redisStart = performance.now();
39
+ const redis = getRedis();
40
+
41
  if (redis) {
42
  await redis.ping();
43
  const redisLatency = Math.round(performance.now() - redisStart);
app/api/performance/metrics/route.ts CHANGED
@@ -5,6 +5,7 @@ export async function GET() {
5
  try {
6
  // Get summary of web vitals and API metrics
7
  const summary = performanceMonitor.getSummary();
 
8
 
9
  return NextResponse.json({
10
  lcp: summary.lcp,
@@ -15,6 +16,7 @@ export async function GET() {
15
  cachedRequests: summary.cachedRequests,
16
  totalRequests: summary.totalRequests,
17
  cacheHitRate: summary.cacheHitRate,
 
18
  });
19
  } catch (error) {
20
  console.error("Failed to get performance metrics:", error);
 
5
  try {
6
  // Get summary of web vitals and API metrics
7
  const summary = performanceMonitor.getSummary();
8
+ const apiMetrics = performanceMonitor.getAPIMetrics();
9
 
10
  return NextResponse.json({
11
  lcp: summary.lcp,
 
16
  cachedRequests: summary.cachedRequests,
17
  totalRequests: summary.totalRequests,
18
  cacheHitRate: summary.cacheHitRate,
19
+ apiMetrics,
20
  });
21
  } catch (error) {
22
  console.error("Failed to get performance metrics:", error);
app/api/social/automations/create/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
  import { auth } from "@/auth";
3
  import { db } from "@/db";
4
  import { socialAutomations, connectedAccounts } from "@/db/schema";
5
- import { eq } from "drizzle-orm";
6
 
7
  export async function POST(req: NextRequest) {
8
  const session = await auth();
@@ -12,22 +12,38 @@ export async function POST(req: NextRequest) {
12
 
13
  try {
14
  const body = await req.json();
15
- const { name, triggerType, keywords, actionType, responseTemplate } = body;
 
 
 
 
 
 
 
16
 
17
  if (!name || !triggerType || !actionType || !responseTemplate) {
18
  return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
19
  }
20
 
21
- // For whatsapp_command, we don't need a connected account as it uses the user's global WhatsApp config
 
 
22
  let accountId = null;
23
 
24
- if (triggerType !== 'whatsapp_command') {
 
 
 
 
25
  const account = await db.query.connectedAccounts.findFirst({
26
- where: eq(connectedAccounts.userId, session.user.id)
 
 
 
27
  });
28
 
29
  if (!account) {
30
- return NextResponse.json({ error: "No connected social account found" }, { status: 400 });
31
  }
32
  accountId = account.id;
33
  }
 
2
  import { auth } from "@/auth";
3
  import { db } from "@/db";
4
  import { socialAutomations, connectedAccounts } from "@/db/schema";
5
+ import { and, eq } from "drizzle-orm";
6
 
7
  export async function POST(req: NextRequest) {
8
  const session = await auth();
 
12
 
13
  try {
14
  const body = await req.json();
15
+ const {
16
+ name,
17
+ triggerType,
18
+ keywords,
19
+ actionType,
20
+ responseTemplate,
21
+ connectedAccountId,
22
+ } = body;
23
 
24
  if (!name || !triggerType || !actionType || !responseTemplate) {
25
  return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
26
  }
27
 
28
+ const isWhatsAppTrigger =
29
+ triggerType === "whatsapp_command" || triggerType === "whatsapp_keyword";
30
+
31
  let accountId = null;
32
 
33
+ if (!isWhatsAppTrigger) {
34
+ if (!connectedAccountId) {
35
+ return NextResponse.json({ error: "Connected account is required" }, { status: 400 });
36
+ }
37
+
38
  const account = await db.query.connectedAccounts.findFirst({
39
+ where: and(
40
+ eq(connectedAccounts.id, connectedAccountId),
41
+ eq(connectedAccounts.userId, session.user.id)
42
+ )
43
  });
44
 
45
  if (!account) {
46
+ return NextResponse.json({ error: "Connected social account not found" }, { status: 400 });
47
  }
48
  accountId = account.id;
49
  }
app/api/social/automations/route.ts CHANGED
@@ -14,10 +14,20 @@ export async function GET() {
14
  try {
15
  const automations = await db.query.socialAutomations.findMany({
16
  where: eq(socialAutomations.userId, session.user.id),
17
- orderBy: [desc(socialAutomations.createdAt)]
 
 
 
18
  });
19
 
20
- return NextResponse.json(automations);
 
 
 
 
 
 
 
21
  } catch (error) {
22
  console.error("Error fetching automations:", error);
23
  return NextResponse.json({ error: "Failed to fetch automations" }, { status: 500 });
 
14
  try {
15
  const automations = await db.query.socialAutomations.findMany({
16
  where: eq(socialAutomations.userId, session.user.id),
17
+ orderBy: [desc(socialAutomations.createdAt)],
18
+ with: {
19
+ account: true,
20
+ },
21
  });
22
 
23
+ return NextResponse.json(
24
+ automations.map((automation) => ({
25
+ ...automation,
26
+ platform: automation.account?.provider || (
27
+ automation.triggerType.startsWith("whatsapp_") ? "whatsapp" : "unassigned"
28
+ ),
29
+ }))
30
+ );
31
  } catch (error) {
32
  console.error("Error fetching automations:", error);
33
  return NextResponse.json({ error: "Failed to fetch automations" }, { status: 500 });
app/api/social/callback/[provider]/route.ts CHANGED
@@ -78,39 +78,95 @@ export async function GET(
78
  const expiresSeconds = exchangeData.expires_in || 5184000; // 60 days fallback
79
  const expiresAt = new Date(Date.now() + expiresSeconds * 1000);
80
 
81
- // 3. Fetch User Profile (to get name/id)
82
- const meUrl = `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${longLivedToken}`;
83
- const meRes = await fetch(meUrl);
84
- const meData = await meRes.json();
85
 
86
- // 4. Save to DB
87
- // Check if exists
88
- const existingAccount = await db.query.connectedAccounts.findFirst({
89
- where: and(
90
- eq(connectedAccounts.userId, userId),
91
- eq(connectedAccounts.provider, "facebook")
92
- )
93
- });
94
 
95
- if (existingAccount) {
96
- await db.update(connectedAccounts).set({
97
- accessToken: longLivedToken,
98
- expiresAt: expiresAt,
99
- updatedAt: new Date(),
100
- name: meData.name,
101
- picture: meData.picture?.data?.url,
102
- providerAccountId: meData.id
103
- }).where(eq(connectedAccounts.id, existingAccount.id));
104
- } else {
105
- await db.insert(connectedAccounts).values({
106
- userId: userId,
107
- provider: "facebook",
108
- providerAccountId: meData.id,
109
- accessToken: longLivedToken,
110
- expiresAt: expiresAt,
111
- name: meData.name,
112
- picture: meData.picture?.data?.url
113
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
  return NextResponse.redirect(new URL("/dashboard/settings?success=connected", effectiveBaseUrl));
 
78
  const expiresSeconds = exchangeData.expires_in || 5184000; // 60 days fallback
79
  const expiresAt = new Date(Date.now() + expiresSeconds * 1000);
80
 
81
+ // 3. Fetch managed pages and linked Instagram business accounts.
82
+ const pagesUrl = `https://graph.facebook.com/v21.0/me/accounts?fields=id,name,picture{url},access_token,instagram_business_account{id,username,profile_picture_url}&access_token=${longLivedToken}`;
83
+ const pagesRes = await fetch(pagesUrl);
84
+ const pagesData = await pagesRes.json();
85
 
86
+ if (pagesData.error) {
87
+ throw new Error(pagesData.error.message);
88
+ }
 
 
 
 
 
89
 
90
+ const pages = pagesData.data || [];
91
+
92
+ if (pages.length === 0) {
93
+ throw new Error("No Facebook Pages found. Connect a Page with the required permissions first.");
94
+ }
95
+
96
+ for (const page of pages) {
97
+ const existingFacebookAccount = await db.query.connectedAccounts.findFirst({
98
+ where: and(
99
+ eq(connectedAccounts.userId, userId),
100
+ eq(connectedAccounts.provider, "facebook"),
101
+ eq(connectedAccounts.providerAccountId, page.id)
102
+ )
 
 
 
 
 
103
  });
104
+
105
+ const facebookPayload = {
106
+ accessToken: page.access_token || longLivedToken,
107
+ expiresAt,
108
+ updatedAt: new Date(),
109
+ name: page.name,
110
+ picture: page.picture?.data?.url,
111
+ metadata: {
112
+ type: "facebook_page",
113
+ source: "meta_oauth",
114
+ },
115
+ };
116
+
117
+ if (existingFacebookAccount) {
118
+ await db
119
+ .update(connectedAccounts)
120
+ .set(facebookPayload)
121
+ .where(eq(connectedAccounts.id, existingFacebookAccount.id));
122
+ } else {
123
+ await db.insert(connectedAccounts).values({
124
+ userId,
125
+ provider: "facebook",
126
+ providerAccountId: page.id,
127
+ ...facebookPayload,
128
+ });
129
+ }
130
+
131
+ const instagramAccount = page.instagram_business_account;
132
+
133
+ if (instagramAccount?.id) {
134
+ const existingInstagramAccount = await db.query.connectedAccounts.findFirst({
135
+ where: and(
136
+ eq(connectedAccounts.userId, userId),
137
+ eq(connectedAccounts.provider, "instagram"),
138
+ eq(connectedAccounts.providerAccountId, instagramAccount.id)
139
+ )
140
+ });
141
+
142
+ const instagramPayload = {
143
+ accessToken: page.access_token || longLivedToken,
144
+ expiresAt,
145
+ updatedAt: new Date(),
146
+ name: instagramAccount.username || page.name,
147
+ picture: instagramAccount.profile_picture_url || page.picture?.data?.url,
148
+ metadata: {
149
+ type: "instagram_business",
150
+ pageId: page.id,
151
+ pageName: page.name,
152
+ source: "meta_oauth",
153
+ },
154
+ };
155
+
156
+ if (existingInstagramAccount) {
157
+ await db
158
+ .update(connectedAccounts)
159
+ .set(instagramPayload)
160
+ .where(eq(connectedAccounts.id, existingInstagramAccount.id));
161
+ } else {
162
+ await db.insert(connectedAccounts).values({
163
+ userId,
164
+ provider: "instagram",
165
+ providerAccountId: instagramAccount.id,
166
+ ...instagramPayload,
167
+ });
168
+ }
169
+ }
170
  }
171
 
172
  return NextResponse.redirect(new URL("/dashboard/settings?success=connected", effectiveBaseUrl));
app/api/social/connect/[provider]/route.ts CHANGED
@@ -31,9 +31,14 @@ export async function GET(
31
  const scope = [
32
  "pages_show_list",
33
  "pages_read_engagement",
 
 
34
  "pages_manage_posts", // For posting
 
35
  "instagram_basic", // For IG info
36
  "instagram_content_publish", // For IG posting
 
 
37
  "business_management", // General access
38
  ].join(",");
39
 
 
31
  const scope = [
32
  "pages_show_list",
33
  "pages_read_engagement",
34
+ "pages_manage_engagement",
35
+ "pages_manage_metadata",
36
  "pages_manage_posts", // For posting
37
+ "pages_messaging",
38
  "instagram_basic", // For IG info
39
  "instagram_content_publish", // For IG posting
40
+ "instagram_manage_comments",
41
+ "instagram_manage_messages",
42
  "business_management", // General access
43
  ].join(",");
44
 
app/api/social/posts/create/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
  import { auth } from "@/auth";
3
  import { db } from "@/db";
4
  import { socialPosts, connectedAccounts } from "@/db/schema";
5
- import { eq } from "drizzle-orm";
6
  import { socialPublisher } from "@/lib/social/publisher";
7
  const { publishToFacebook, publishToInstagram } = socialPublisher;
8
 
@@ -22,7 +22,10 @@ export async function POST(req: NextRequest) {
22
 
23
  // 1. Fetch Connected Account to get Access Token
24
  const account = await db.query.connectedAccounts.findFirst({
25
- where: eq(connectedAccounts.id, connectedAccountId)
 
 
 
26
  });
27
 
28
  if (!account) {
@@ -61,10 +64,10 @@ export async function POST(req: NextRequest) {
61
  platform,
62
  status: "published",
63
  publishedAt: new Date(),
64
- platformPostId: result?.id
65
  });
66
 
67
- return NextResponse.json({ success: true, postId: result?.id });
68
 
69
  } catch (error: unknown) {
70
  const msg = error instanceof Error ? error.message : String(error);
 
2
  import { auth } from "@/auth";
3
  import { db } from "@/db";
4
  import { socialPosts, connectedAccounts } from "@/db/schema";
5
+ import { and, eq } from "drizzle-orm";
6
  import { socialPublisher } from "@/lib/social/publisher";
7
  const { publishToFacebook, publishToInstagram } = socialPublisher;
8
 
 
22
 
23
  // 1. Fetch Connected Account to get Access Token
24
  const account = await db.query.connectedAccounts.findFirst({
25
+ where: and(
26
+ eq(connectedAccounts.id, connectedAccountId),
27
+ eq(connectedAccounts.userId, session.user.id)
28
+ )
29
  });
30
 
31
  if (!account) {
 
64
  platform,
65
  status: "published",
66
  publishedAt: new Date(),
67
+ platformPostId: typeof result === "string" ? result : result?.id
68
  });
69
 
70
+ return NextResponse.json({ success: true, postId: typeof result === "string" ? result : result?.id });
71
 
72
  } catch (error: unknown) {
73
  const msg = error instanceof Error ? error.message : String(error);
app/api/social/posts/route.ts CHANGED
@@ -52,7 +52,10 @@ export async function POST(req: NextRequest) {
52
  for (const accountId of platforms) {
53
  // Fetch account details
54
  const account = await db.query.connectedAccounts.findFirst({
55
- where: eq(connectedAccounts.id, accountId)
 
 
 
56
  });
57
 
58
  if (!account) {
@@ -94,29 +97,22 @@ export async function POST(req: NextRequest) {
94
 
95
  const payload = {
96
  content,
97
- title, // Pass title
98
  mediaUrl: fullMediaUrl,
99
  accessToken: account.accessToken,
100
  providerAccountId: account.providerAccountId,
101
  refreshToken: account.refreshToken || undefined
102
  };
103
 
104
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
- const provider = account.provider as any;
106
-
107
- if (provider === "facebook") {
108
  platformPostId = await socialPublisher.publishToFacebook(payload);
109
- } else if (provider === "instagram") {
110
- const metadata = account.metadata as Record<string, unknown> | null;
111
- if (provider === "facebook" && metadata?.type === "instagram") {
112
- platformPostId = await socialPublisher.publishToFacebook(payload);
113
- } else {
114
- platformPostId = await socialPublisher.publishToFacebook(payload);
115
- }
116
- } else if (provider === "linkedin") {
117
  platformPostId = await socialPublisher.publishToLinkedin(payload);
118
- } else if (provider === "youtube") {
119
  platformPostId = await socialPublisher.publishToYoutube(payload);
 
 
120
  }
121
 
122
  // Update DB Success
 
52
  for (const accountId of platforms) {
53
  // Fetch account details
54
  const account = await db.query.connectedAccounts.findFirst({
55
+ where: and(
56
+ eq(connectedAccounts.id, accountId),
57
+ eq(connectedAccounts.userId, userId)
58
+ )
59
  });
60
 
61
  if (!account) {
 
97
 
98
  const payload = {
99
  content,
 
100
  mediaUrl: fullMediaUrl,
101
  accessToken: account.accessToken,
102
  providerAccountId: account.providerAccountId,
103
  refreshToken: account.refreshToken || undefined
104
  };
105
 
106
+ if (account.provider === "facebook") {
 
 
 
107
  platformPostId = await socialPublisher.publishToFacebook(payload);
108
+ } else if (account.provider === "instagram") {
109
+ platformPostId = await socialPublisher.publishToInstagram(payload);
110
+ } else if (account.provider === "linkedin") {
 
 
 
 
 
111
  platformPostId = await socialPublisher.publishToLinkedin(payload);
112
+ } else if (account.provider === "youtube") {
113
  platformPostId = await socialPublisher.publishToYoutube(payload);
114
+ } else {
115
+ throw new Error(`Unsupported publishing provider: ${account.provider}`);
116
  }
117
 
118
  // Update DB Success
app/api/social/webhooks/facebook/route.ts CHANGED
@@ -1,244 +1,313 @@
1
- /**
2
- * Facebook Webhook Handler
3
- * Handles real-time webhook events from Facebook/Instagram
4
- */
5
 
 
6
  import { NextRequest, NextResponse } from "next/server";
 
7
  import { db } from "@/db";
8
- import { socialAutomations, connectedAccounts } from "@/db/schema";
9
- import { eq } from "drizzle-orm";
10
- import crypto from "crypto";
11
- /**
12
- * GET handler for webhook verification
13
- * Facebook requires this for webhook setup
14
- */
15
- export async function GET(request: NextRequest) {
16
- const searchParams = request.nextUrl.searchParams;
17
 
18
- const mode = searchParams.get("hub.mode");
19
- const token = searchParams.get("hub.verify_token");
20
- const challenge = searchParams.get("hub.challenge");
21
 
22
- // Verify token (should match the one set in Facebook App dashboard)
23
- const VERIFY_TOKEN = process.env.FACEBOOK_WEBHOOK_VERIFY_TOKEN || "autoloop_webhook_token_2024";
 
 
 
24
 
25
- if (mode === "subscribe" && token === VERIFY_TOKEN) {
26
- console.log("✅ Webhook verified");
27
- return new NextResponse(challenge, { status: 200 });
28
- } else {
29
- console.error("❌ Webhook verification failed");
30
- return NextResponse.json({ error: "Verification failed" }, { status: 403 });
31
- }
32
  }
33
 
34
- /**
35
- * POST handler for webhook events
36
- * Receives real-time updates from Facebook/Instagram
37
- */
38
  export async function POST(request: NextRequest) {
39
- try {
40
- const body = await request.json();
41
-
42
- console.log("📨 Received webhook event:", JSON.stringify(body, null, 2));
43
-
44
- // Verify the webhook signature (recommended for production)
45
- // const signature = request.headers.get("x-hub-signature-256");
46
- // if (!verifySignature(body, signature)) {
47
- // return NextResponse.json({ error: "Invalid signature" }, { status: 403 });
48
- // }
49
-
50
- // Process each entry in the webhook
51
- if (body.object === "page" || body.object === "instagram") {
52
- for (const entry of body.entry || []) {
53
- // Handle different webhook fields
54
- if (entry.changes) {
55
- for (const change of entry.changes) {
56
- await handleWebhookChange(change, entry.id);
57
- }
58
- }
59
-
60
- if (entry.messaging) {
61
- for (const message of entry.messaging) {
62
- await handleMessagingEvent(message, entry.id);
63
- }
64
- }
65
- }
66
  }
 
67
 
68
- // Facebook expects a 200 OK response
69
- return NextResponse.json({ success: true }, { status: 200 });
70
- } catch (error) {
71
- console.error("❌ Error processing webhook:", error);
72
- // Still return 200 to prevent Facebook from retrying
73
- return NextResponse.json({ success: false }, { status: 200 });
74
  }
 
 
 
 
 
 
75
  }
76
 
77
- /**
78
- * Handle webhook change events (comments, posts, etc.)
79
- */
80
- async function handleWebhookChange(change: Record<string, unknown>, pageId: string) {
81
- const { field, value } = change;
82
-
83
- console.log(`📝 Webhook change: ${field}`, value);
84
-
85
- switch (field) {
86
- case "comments":
87
- await handleCommentEvent(value as Record<string, unknown>, pageId);
88
- break;
89
- case "feed":
90
- await handleFeedEvent(value as Record<string, unknown>, pageId);
91
- break;
92
- case "mentions":
93
- await handleMentionEvent(value as Record<string, unknown>, pageId);
94
- break;
95
- default:
96
- console.log(`ℹ️ Unhandled webhook field: ${field}`);
97
- }
98
  }
99
 
100
- /**
101
- * Handle comment events
102
- */
103
- async function handleCommentEvent(value: Record<string, unknown>, pageId: string) {
104
- const commentData = value as {
105
- id?: string;
106
- post_id?: string;
107
- message?: string;
108
- from?: { id: string; name: string };
109
- created_time?: string;
110
- parent_id?: string; // For comment replies
111
- };
112
-
113
- if (!commentData.message || !commentData.from) {
114
- console.log("⚠️ Incomplete comment data");
115
- return;
116
- }
117
 
118
- console.log(`💬 New comment: "${commentData.message.substring(0, 50)}..." by ${commentData.from.name}`);
 
 
119
 
120
- // Find matching automations
121
- const account = await db.query.connectedAccounts.findFirst({
122
- where: eq(connectedAccounts.providerAccountId, pageId),
123
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- if (!account) {
126
- console.log(`⚠️ No account found for page ${pageId}`);
127
- return;
 
 
 
 
128
  }
129
 
130
- const automations = await db.query.socialAutomations.findMany({
131
- where: eq(socialAutomations.connectedAccountId, account.id),
132
- });
 
133
 
134
- // Check each automation for keyword matches
135
- for (const automation of automations) {
136
- if (!automation.isActive) continue;
 
 
 
 
137
 
138
- if (automation.triggerType !== "comment_keyword" && automation.triggerType !== "any_comment") {
139
- continue;
140
- }
141
 
142
- // Check keywords
143
- const keywords = automation.keywords || [];
144
- const matchedKeyword = keywords.find(keyword =>
145
- commentData.message!.toLowerCase().includes(keyword.toLowerCase())
146
- );
147
-
148
- if (matchedKeyword || automation.triggerType === "any_comment") {
149
- console.log(`✅ Matched automation: "${automation.name}"`);
150
-
151
- // Execute auto-reply
152
- await executeAutoReplyToComment(
153
- commentData.id!,
154
- automation.responseTemplate || "Thank you for your comment!",
155
- account.accessToken,
156
- account.provider
157
- );
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
 
 
 
 
 
 
 
 
160
  }
161
 
162
- /**
163
- * Handle feed events (new posts)
164
- */
165
- async function handleFeedEvent(value: Record<string, unknown>, pageId: string) {
166
- console.log(`📰 Feed event for page ${pageId}`);
167
- // Could trigger automations based on new posts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
 
170
- /**
171
- * Handle mention events
172
- */
173
- async function handleMentionEvent(value: Record<string, unknown>, pageId: string) {
174
- console.log(`@️ Mention event for page ${pageId}`);
175
- // Could trigger automations based on mentions
176
  }
177
 
178
- /**
179
- * Handle messaging events (DMs)
180
- */
181
- async function handleMessagingEvent(message: Record<string, unknown>, pageId: string) {
182
- console.log(`📬 Messaging event for page ${pageId}`, message);
183
- // Could handle DM-based automations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
 
186
- /**
187
- * Execute auto-reply to a comment
188
- */
189
- async function executeAutoReplyToComment(
190
- commentId: string,
191
- replyText: string,
192
- accessToken: string,
193
- provider: string
194
  ) {
195
- try {
196
- let url = "";
197
-
198
- if (provider === "facebook") {
199
- url = `https://graph.facebook.com/v21.0/${commentId}/comments`;
200
- } else if (provider === "instagram") {
201
- url = `https://graph.facebook.com/v21.0/${commentId}/replies`;
202
- } else {
203
- console.log(`⚠️ Platform ${provider} not supported`);
204
- return;
205
- }
 
 
 
206
 
207
- const response = await fetch(url, {
208
- method: "POST",
209
- headers: { "Content-Type": "application/json" },
210
- body: JSON.stringify({
211
- message: replyText,
212
- access_token: accessToken,
213
- }),
214
- });
215
-
216
- const data = await response.json();
217
-
218
- if (data.error) {
219
- console.error("❌ Error posting reply:", data.error);
220
- } else {
221
- console.log(`✅ Auto-reply posted successfully`);
222
- }
223
- } catch (error) {
224
- console.error("❌ Error in executeAutoReplyToComment:", error);
225
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  }
227
 
228
- /**
229
- * Verify webhook signature (optional but recommended)
230
- * Currently unused but kept for future implementation
231
- */
232
- /* eslint-disable @typescript-eslint/no-unused-vars */
233
- function verifySignature(body: unknown, signature: string | null): boolean {
234
- if (!signature) return false;
235
- const APP_SECRET = process.env.FACEBOOK_APP_SECRET || "";
236
-
237
- const expectedSignature = "sha256=" + crypto
238
- .createHmac("sha256", APP_SECRET)
239
- .update(JSON.stringify(body))
240
- .digest("hex");
241
-
242
- return signature === expectedSignature;
243
  }
244
- /* eslint-enable @typescript-eslint/no-unused-vars */
 
1
+ import crypto from "crypto";
 
 
 
2
 
3
+ import { and, eq, inArray } from "drizzle-orm";
4
  import { NextRequest, NextResponse } from "next/server";
5
+
6
  import { db } from "@/db";
7
+ import { connectedAccounts, socialAutomations } from "@/db/schema";
8
+ import { buildSocialEventKey, claimSocialEvent } from "@/lib/social/event-dedupe";
 
 
 
 
 
 
 
9
 
10
+ const VERIFY_TOKEN = process.env.FACEBOOK_WEBHOOK_VERIFY_TOKEN || "autoloop_webhook_token_2026";
 
 
11
 
12
+ export async function GET(request: NextRequest) {
13
+ const searchParams = request.nextUrl.searchParams;
14
+ const mode = searchParams.get("hub.mode");
15
+ const token = searchParams.get("hub.verify_token");
16
+ const challenge = searchParams.get("hub.challenge");
17
 
18
+ if (mode === "subscribe" && token === VERIFY_TOKEN) {
19
+ return new NextResponse(challenge, { status: 200 });
20
+ }
21
+
22
+ return NextResponse.json({ error: "Verification failed" }, { status: 403 });
 
 
23
  }
24
 
 
 
 
 
25
  export async function POST(request: NextRequest) {
26
+ try {
27
+ const rawBody = await request.text();
28
+ const signature = request.headers.get("x-hub-signature-256");
29
+
30
+ if (!verifySignature(rawBody, signature)) {
31
+ return NextResponse.json({ error: "Invalid signature" }, { status: 403 });
32
+ }
33
+
34
+ const body = JSON.parse(rawBody);
35
+
36
+ if (body.object !== "page" && body.object !== "instagram") {
37
+ return NextResponse.json({ success: true }, { status: 200 });
38
+ }
39
+
40
+ for (const entry of body.entry || []) {
41
+ if (entry.changes) {
42
+ for (const change of entry.changes) {
43
+ await handleWebhookChange(change, entry.id);
 
 
 
 
 
 
 
 
 
44
  }
45
+ }
46
 
47
+ if (entry.messaging) {
48
+ for (const message of entry.messaging) {
49
+ await handleMessagingEvent(message, entry.id);
50
+ }
51
+ }
 
52
  }
53
+
54
+ return NextResponse.json({ success: true }, { status: 200 });
55
+ } catch (error) {
56
+ console.error("Error processing Meta webhook:", error);
57
+ return NextResponse.json({ success: false }, { status: 200 });
58
+ }
59
  }
60
 
61
+ async function handleWebhookChange(change: Record<string, unknown>, providerAccountId: string) {
62
+ const field = String(change.field || "");
63
+ const value = (change.value || {}) as Record<string, unknown>;
64
+
65
+ if (field === "comments" || field === "feed") {
66
+ await handleCommentEvent(value, providerAccountId);
67
+ return;
68
+ }
69
+
70
+ if (field === "mentions") {
71
+ await handleMentionEvent(value, providerAccountId);
72
+ }
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
+ async function handleCommentEvent(value: Record<string, unknown>, providerAccountId: string) {
76
+ const account = await findAccount(providerAccountId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
+ if (!account) {
79
+ return;
80
+ }
81
 
82
+ const commentId = String(value.comment_id || value.id || "");
83
+ const message = String(value.message || "");
84
+
85
+ if (!commentId || !message) {
86
+ return;
87
+ }
88
+
89
+ const eventKey = buildSocialEventKey(account.provider, account.id, "comment", commentId);
90
+ const claimed = await claimSocialEvent(eventKey);
91
+
92
+ if (!claimed) {
93
+ return;
94
+ }
95
+
96
+ const author = (value.from || {}) as { id?: string; name?: string };
97
+ const automations = await db.query.socialAutomations.findMany({
98
+ where: and(
99
+ eq(socialAutomations.connectedAccountId, account.id),
100
+ eq(socialAutomations.isActive, true),
101
+ inArray(socialAutomations.triggerType, ["comment_keyword", "any_comment"])
102
+ ),
103
+ });
104
 
105
+ for (const automation of automations) {
106
+ const matchedKeyword = automation.keywords?.find((keyword) =>
107
+ message.toLowerCase().includes(keyword.toLowerCase())
108
+ );
109
+
110
+ if (!matchedKeyword && automation.triggerType !== "any_comment") {
111
+ continue;
112
  }
113
 
114
+ const replyText = (automation.responseTemplate || "Thanks for your comment!")
115
+ .replace(/\{keyword\}/g, matchedKeyword || "")
116
+ .replace(/\{author\}/g, author.name || "there")
117
+ .replace(/\{comment\}/g, message);
118
 
119
+ if (automation.actionType === "reply_comment") {
120
+ await replyToComment(commentId, replyText, account.accessToken, account.provider);
121
+ } else if (automation.actionType === "send_dm" && author.id) {
122
+ await sendDirectMessage(author.id, replyText, account.accessToken, account.provider);
123
+ }
124
+ }
125
+ }
126
 
127
+ async function handleMentionEvent(value: Record<string, unknown>, providerAccountId: string) {
128
+ const account = await findAccount(providerAccountId);
 
129
 
130
+ if (!account) {
131
+ return;
132
+ }
133
+
134
+ const mentionId = String(value.media_id || value.id || "");
135
+
136
+ if (!mentionId) {
137
+ return;
138
+ }
139
+
140
+ const eventKey = buildSocialEventKey(account.provider, account.id, "mention", mentionId);
141
+ const claimed = await claimSocialEvent(eventKey);
142
+
143
+ if (!claimed) {
144
+ return;
145
+ }
146
+
147
+ const automations = await db.query.socialAutomations.findMany({
148
+ where: and(
149
+ eq(socialAutomations.connectedAccountId, account.id),
150
+ eq(socialAutomations.isActive, true),
151
+ eq(socialAutomations.triggerType, "story_mention")
152
+ ),
153
+ });
154
+
155
+ for (const automation of automations) {
156
+ if (automation.actionType !== "send_dm") {
157
+ continue;
158
+ }
159
+
160
+ const mentionerId = String(value.from || value.mentioned_by || "");
161
+
162
+ if (!mentionerId) {
163
+ continue;
164
  }
165
+
166
+ await sendDirectMessage(
167
+ mentionerId,
168
+ automation.responseTemplate || "Thanks for the mention!",
169
+ account.accessToken,
170
+ account.provider
171
+ );
172
+ }
173
  }
174
 
175
+ async function handleMessagingEvent(message: Record<string, unknown>, providerAccountId: string) {
176
+ const account = await findAccount(providerAccountId);
177
+
178
+ if (!account) {
179
+ return;
180
+ }
181
+
182
+ const senderId = String((message.sender as { id?: string } | undefined)?.id || "");
183
+ const messageText = String((message.message as { text?: string } | undefined)?.text || "");
184
+ const messageId = String((message.message as { mid?: string } | undefined)?.mid || "");
185
+
186
+ if (!senderId || !messageText || !messageId) {
187
+ return;
188
+ }
189
+
190
+ const eventKey = buildSocialEventKey(account.provider, account.id, "dm", messageId);
191
+ const claimed = await claimSocialEvent(eventKey);
192
+
193
+ if (!claimed) {
194
+ return;
195
+ }
196
+
197
+ const automations = await db.query.socialAutomations.findMany({
198
+ where: and(
199
+ eq(socialAutomations.connectedAccountId, account.id),
200
+ eq(socialAutomations.isActive, true),
201
+ eq(socialAutomations.triggerType, "dm_keyword")
202
+ ),
203
+ });
204
+
205
+ for (const automation of automations) {
206
+ const matched = automation.keywords?.some((keyword) =>
207
+ messageText.toLowerCase().includes(keyword.toLowerCase())
208
+ );
209
+
210
+ if (!matched) {
211
+ continue;
212
+ }
213
+
214
+ const replyText = (automation.responseTemplate || "Thanks for your message!")
215
+ .replace(/\{author\}/g, senderId)
216
+ .replace(/\{comment\}/g, messageText);
217
+
218
+ await sendDirectMessage(senderId, replyText, account.accessToken, account.provider);
219
+ }
220
  }
221
 
222
+ async function findAccount(providerAccountId: string) {
223
+ return db.query.connectedAccounts.findFirst({
224
+ where: eq(connectedAccounts.providerAccountId, providerAccountId),
225
+ });
 
 
226
  }
227
 
228
+ async function replyToComment(
229
+ commentId: string,
230
+ replyText: string,
231
+ accessToken: string,
232
+ provider: string
233
+ ) {
234
+ const url =
235
+ provider === "instagram"
236
+ ? `https://graph.facebook.com/v21.0/${commentId}/replies`
237
+ : `https://graph.facebook.com/v21.0/${commentId}/comments`;
238
+
239
+ const response = await fetch(url, {
240
+ method: "POST",
241
+ headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({
243
+ message: replyText,
244
+ access_token: accessToken,
245
+ }),
246
+ });
247
+
248
+ const data = await response.json();
249
+
250
+ if (!response.ok || data.error) {
251
+ throw new Error(data.error?.message || "Failed to post comment reply");
252
+ }
253
  }
254
 
255
+ async function sendDirectMessage(
256
+ recipientId: string,
257
+ replyText: string,
258
+ accessToken: string,
259
+ provider: string
 
 
 
260
  ) {
261
+ if (provider === "linkedin") {
262
+ const response = await fetch("https://api.linkedin.com/v2/messages", {
263
+ method: "POST",
264
+ headers: {
265
+ Authorization: `Bearer ${accessToken}`,
266
+ "Content-Type": "application/json",
267
+ "X-Restli-Protocol-Version": "2.0.0",
268
+ },
269
+ body: JSON.stringify({
270
+ recipients: [`urn:li:person:${recipientId}`],
271
+ subject: "Message from AutoLoop",
272
+ body: replyText,
273
+ }),
274
+ });
275
 
276
+ if (!response.ok) {
277
+ const data = await response.text();
278
+ throw new Error(`LinkedIn DM failed: ${data}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  }
280
+
281
+ return;
282
+ }
283
+
284
+ const response = await fetch("https://graph.facebook.com/v21.0/me/messages", {
285
+ method: "POST",
286
+ headers: { "Content-Type": "application/json" },
287
+ body: JSON.stringify({
288
+ recipient: { id: recipientId },
289
+ message: { text: replyText },
290
+ access_token: accessToken,
291
+ }),
292
+ });
293
+
294
+ const data = await response.json();
295
+
296
+ if (!response.ok || data.error) {
297
+ throw new Error(data.error?.message || "Failed to send direct message");
298
+ }
299
  }
300
 
301
+ function verifySignature(rawBody: string, signature: string | null): boolean {
302
+ const appSecret = process.env.FACEBOOK_APP_SECRET;
303
+
304
+ if (!appSecret || !signature) {
305
+ return true;
306
+ }
307
+
308
+ const expectedSignature =
309
+ "sha256=" +
310
+ crypto.createHmac("sha256", appSecret).update(rawBody).digest("hex");
311
+
312
+ return signature === expectedSignature;
 
 
 
313
  }
 
app/api/webhooks/facebook/route.ts CHANGED
@@ -1,125 +1 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { db } from "@/db";
3
- import { socialAutomations, connectedAccounts } from "@/db/schema";
4
- import { eq, and } from "drizzle-orm";
5
- // Not implemented yet, but will be referenced
6
- // import { replyToComment, sendPrivateMessage } from "@/lib/social/publisher";
7
-
8
- // Verification Token for Facebook to verify our endpoint
9
- const VERIFY_TOKEN = process.env.FACEBOOK_WEBHOOK_VERIFY_TOKEN || "autoloop_verify_token";
10
-
11
- export async function GET(req: NextRequest) {
12
- const { searchParams } = new URL(req.url);
13
- const mode = searchParams.get("hub.mode");
14
- const token = searchParams.get("hub.verify_token");
15
- const challenge = searchParams.get("hub.challenge");
16
-
17
- if (mode && token) {
18
- if (mode === "subscribe" && token === VERIFY_TOKEN) {
19
- console.log("WEBHOOK_VERIFIED");
20
- return new NextResponse(challenge, { status: 200 });
21
- } else {
22
- return new NextResponse("Forbidden", { status: 403 });
23
- }
24
- }
25
- return new NextResponse("Bad Request", { status: 400 });
26
- }
27
-
28
- export async function POST(req: NextRequest) {
29
- try {
30
- const body = await req.json();
31
-
32
- if (body.object === "page" || body.object === "instagram") {
33
- // Process each entry
34
- for (const entry of body.entry) {
35
- const entryId = entry.id; // Page ID or IG Business ID
36
-
37
- // Find connected account for this ID to get access token?
38
- // We might need to look up automations FIRST by accountId.
39
-
40
- // Handle Messaging (DMs)
41
- if (entry.messaging) {
42
- for (const event of entry.messaging) {
43
- // event.sender.id, event.message.text
44
- await handleMessagingEvent(entryId, event);
45
- }
46
- }
47
-
48
- // Handle Feed (Comments)
49
- if (entry.changes) {
50
- for (const change of entry.changes) {
51
- if (change.field === "feed" || change.field === "comments") {
52
- await handleFeedEvent(entryId, change.value);
53
- }
54
- }
55
- }
56
- }
57
- return new NextResponse("EVENT_RECEIVED", { status: 200 });
58
- }
59
-
60
- return new NextResponse("Not Found", { status: 404 });
61
-
62
- } catch (error: unknown) {
63
- console.error("Webhook Error:", error instanceof Error ? error.message : String(error));
64
- return new NextResponse("Internal Server Error", { status: 500 });
65
- }
66
- }
67
- async function handleMessagingEvent(accountId: string, event: unknown) {
68
- const evt = event as { sender?: { id?: string }; message?: { text?: string } };
69
- const messageText = evt.message?.text;
70
-
71
- if (!messageText) return;
72
-
73
- // Find automations for this account
74
- const account = await db.query.connectedAccounts.findFirst({
75
- where: eq(connectedAccounts.providerAccountId, accountId)
76
- });
77
-
78
- if (!account) return;
79
-
80
- const automations = await db.query.socialAutomations.findMany({
81
- where: and(
82
- eq(socialAutomations.connectedAccountId, account.id),
83
- eq(socialAutomations.isActive, true),
84
- eq(socialAutomations.triggerType, "dm_keyword")
85
- )
86
- });
87
-
88
- for (const auto of automations) {
89
- if (auto.keywords && auto.keywords.some(k => messageText.toLowerCase().includes(k.toLowerCase()))) {
90
- console.log(`Matched DM Rule: ${auto.name}`);
91
- }
92
- }
93
- }
94
-
95
- async function handleFeedEvent(accountId: string, change: unknown) {
96
- const ch = change as { item?: string; verb?: string; message?: string; comment_id?: string; post_id?: string };
97
- if (ch.item !== "comment" && ch.verb !== "add") return;
98
-
99
- const messageText = ch.message || "";
100
-
101
- const account = await db.query.connectedAccounts.findFirst({
102
- where: eq(connectedAccounts.providerAccountId, accountId)
103
- });
104
-
105
- if (!account) return;
106
-
107
- const automations = await db.query.socialAutomations.findMany({
108
- where: and(
109
- eq(socialAutomations.connectedAccountId, account.id),
110
- eq(socialAutomations.isActive, true)
111
- )
112
- });
113
-
114
- for (const auto of automations) {
115
- let isMatch = false;
116
- if (auto.triggerType === "any_comment") isMatch = true;
117
- if (auto.triggerType === "comment_keyword" && auto.keywords) {
118
- isMatch = auto.keywords.some(k => messageText.toLowerCase().includes(k.toLowerCase()));
119
- }
120
-
121
- if (isMatch) {
122
- console.log(`Matched Feed Rule: ${auto.name}`);
123
- }
124
- }
125
- }
 
1
+ export { GET, POST } from "@/app/api/social/webhooks/facebook/route";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/dashboard/businesses/page.tsx CHANGED
@@ -36,7 +36,7 @@ export default function BusinessesPage() {
36
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
37
  const [isModalOpen, setIsModalOpen] = useState(false);
38
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
39
- const [csrfToken, setCsrfToken] = useState("");
40
 
41
  // Pagination state
42
  const [currentPage, setCurrentPage] = useState(1);
@@ -59,11 +59,6 @@ export default function BusinessesPage() {
59
  const { get: getBusinessesApi, loading: loadingBusinesses } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
60
  const { get: getCategoriesApi } = useApi<{ categories: string[] }>();
61
 
62
- // Generate CSRF token on mount
63
- useEffect(() => {
64
- setCsrfToken(generateCsrfToken());
65
- }, []);
66
-
67
  // Debounce effect
68
  useEffect(() => {
69
  const timer = setTimeout(() => {
 
36
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
37
  const [isModalOpen, setIsModalOpen] = useState(false);
38
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
39
+ const [csrfToken] = useState(() => generateCsrfToken());
40
 
41
  // Pagination state
42
  const [currentPage, setCurrentPage] = useState(1);
 
59
  const { get: getBusinessesApi, loading: loadingBusinesses } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
60
  const { get: getCategoriesApi } = useApi<{ categories: string[] }>();
61
 
 
 
 
 
 
62
  // Debounce effect
63
  useEffect(() => {
64
  const timer = setTimeout(() => {
app/dashboard/social/automations/new/page.tsx CHANGED
@@ -1,147 +1,261 @@
1
  "use client";
2
 
3
- import { useState } from "react";
 
 
 
 
 
4
  import { Button } from "@/components/ui/button";
5
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
  import { Input } from "@/components/ui/input";
7
  import { Label } from "@/components/ui/label";
8
- import { Textarea } from "@/components/ui/textarea";
9
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10
- import { toast } from "sonner";
11
- import { useRouter } from "next/navigation";
12
- import { Loader2, ArrowLeft } from "lucide-react";
13
- import Link from "next/link";
14
 
15
- // Mock accounts for now or implement fetching
16
- // We'll reuse the pattern: UI Client Component + Server Action later
17
- // For MVP speed, let's hardcode account selection or make it "All Accounts"
 
 
 
18
 
19
  export default function NewAutomationPage() {
20
- const router = useRouter();
21
- const [submitting, setSubmitting] = useState(false);
22
-
23
- // Form State
24
- const [name, setName] = useState("");
25
- const [triggerType, setTriggerType] = useState("comment_keyword");
26
- const [keywords, setKeywords] = useState("");
27
- const [actionType, setActionType] = useState("reply_comment");
28
- const [responseTemplate, setResponseTemplate] = useState("");
29
-
30
- const handleSubmit = async () => {
31
- if (!name || !responseTemplate) {
32
- toast.error("Please fill required fields");
33
- return;
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
- setSubmitting(true);
37
- try {
38
- // Need an API endpoint to save this
39
- // We'll create app/api/social/automations/create/route.ts next
40
- const res = await fetch("/api/social/automations/create", {
41
- method: "POST",
42
- headers: { "Content-Type": "application/json" },
43
- body: JSON.stringify({
44
- name,
45
- triggerType,
46
- keywords: keywords.split(",").map(k => k.trim()).filter(k => k),
47
- actionType,
48
- responseTemplate
49
- })
50
- });
51
-
52
- const data = await res.json(); // Fixed: parse response
53
- if (!res.ok) throw new Error(data.error);
54
-
55
- toast.success("Rule created successfully!");
56
- router.push("/dashboard/social/automations");
57
- } catch (error: unknown) {
58
- const msg = error instanceof Error ? error.message : String(error);
59
- toast.error(msg || "Failed to create rule");
60
- } finally {
61
- setSubmitting(false); // Fixed: ensure loader stops
62
  }
 
 
 
 
 
 
 
63
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
- return (
66
- <div className="max-w-2xl mx-auto p-6 space-y-6">
67
- <div className="flex items-center gap-4">
68
- <Button variant="ghost" size="icon" asChild>
69
- <Link href="/dashboard/social/automations"><ArrowLeft className="h-4 w-4" /></Link>
70
- </Button>
71
- <h1 className="text-2xl font-bold">Create Automation Rule</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- <Card>
75
- <CardHeader>
76
- <CardTitle>Rule Configuration</CardTitle>
77
- </CardHeader>
78
- <CardContent className="space-y-4">
79
- <div className="space-y-2">
80
- <Label>Rule Name</Label>
81
- <Input
82
- placeholder="e.g. Pricing Auto-Reply"
83
- value={name}
84
- onChange={(e) => setName(e.target.value)}
85
- />
86
- </div>
87
-
88
- <div className="grid grid-cols-2 gap-4">
89
- <div className="space-y-2">
90
- <Label>Trigger</Label>
91
- <Select value={triggerType} onValueChange={setTriggerType}>
92
- <SelectTrigger>
93
- <SelectValue />
94
- </SelectTrigger>
95
- <SelectContent>
96
- <SelectItem value="comment_keyword">Comment Keyword</SelectItem>
97
- <SelectItem value="dm_keyword">DM Keyword</SelectItem>
98
- <SelectItem value="any_comment">Any Comment</SelectItem>
99
- <SelectItem value="whatsapp_keyword">WhatsApp Message</SelectItem>
100
- </SelectContent>
101
- </Select>
102
- </div>
103
-
104
- <div className="space-y-2">
105
- <Label>Action</Label>
106
- <Select value={actionType} onValueChange={setActionType}>
107
- <SelectTrigger>
108
- <SelectValue />
109
- </SelectTrigger>
110
- <SelectContent>
111
- <SelectItem value="reply_comment">Reply to Comment</SelectItem>
112
- <SelectItem value="send_dm">Send Private Message</SelectItem>
113
- </SelectContent>
114
- </Select>
115
- </div>
116
- </div>
117
-
118
- {(triggerType === "comment_keyword" || triggerType === "dm_keyword") && (
119
- <div className="space-y-2">
120
- <Label>Keywords (comma separated)</Label>
121
- <Input
122
- placeholder="price, cost, how much"
123
- value={keywords}
124
- onChange={(e) => setKeywords(e.target.value)}
125
- />
126
- </div>
127
- )}
128
-
129
- <div className="space-y-2">
130
- <Label>Response Message</Label>
131
- <Textarea
132
- placeholder="Hi there! Our pricing starts at..."
133
- className="min-h-[100px]"
134
- value={responseTemplate}
135
- onChange={(e) => setResponseTemplate(e.target.value)}
136
- />
137
- </div>
138
-
139
- <Button onClick={handleSubmit} className="w-full" disabled={submitting}>
140
- {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
141
- Create Rule
142
- </Button>
143
- </CardContent>
144
- </Card>
145
- </div>
146
- );
147
  }
 
1
  "use client";
2
 
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import { ArrowLeft, Loader2 } from "lucide-react";
7
+ import { toast } from "sonner";
8
+
9
  import { Button } from "@/components/ui/button";
10
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
11
  import { Input } from "@/components/ui/input";
12
  import { Label } from "@/components/ui/label";
 
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
14
+ import { Textarea } from "@/components/ui/textarea";
 
 
 
15
 
16
+ interface ConnectedAccount {
17
+ id: string;
18
+ provider: string;
19
+ name?: string | null;
20
+ providerAccountId: string;
21
+ }
22
 
23
  export default function NewAutomationPage() {
24
+ const router = useRouter();
25
+
26
+ const [submitting, setSubmitting] = useState(false);
27
+ const [accounts, setAccounts] = useState<ConnectedAccount[]>([]);
28
+ const [loadingAccounts, setLoadingAccounts] = useState(true);
29
+
30
+ const [name, setName] = useState("");
31
+ const [triggerType, setTriggerType] = useState("comment_keyword");
32
+ const [keywords, setKeywords] = useState("");
33
+ const [actionType, setActionType] = useState("reply_comment");
34
+ const [responseTemplate, setResponseTemplate] = useState("");
35
+ const [connectedAccountId, setConnectedAccountId] = useState("");
36
+
37
+ const isWhatsAppTrigger = triggerType === "whatsapp_keyword" || triggerType === "whatsapp_command";
38
+
39
+ useEffect(() => {
40
+ let active = true;
41
+
42
+ async function loadAccounts() {
43
+ try {
44
+ const response = await fetch("/api/settings");
45
+ const data = await response.json();
46
+
47
+ if (!response.ok) {
48
+ throw new Error(data.error || "Failed to load connected accounts");
49
  }
50
 
51
+ if (!active) {
52
+ return;
53
+ }
54
+
55
+ const socialAccounts = (data.connectedAccounts || []).filter((account: ConnectedAccount) =>
56
+ ["facebook", "instagram", "linkedin"].includes(account.provider)
57
+ );
58
+
59
+ setAccounts(socialAccounts);
60
+
61
+ if (socialAccounts.length > 0) {
62
+ setConnectedAccountId(socialAccounts[0].id);
63
+ }
64
+ } catch (error) {
65
+ console.error(error);
66
+ toast.error("Failed to load connected accounts");
67
+ } finally {
68
+ if (active) {
69
+ setLoadingAccounts(false);
 
 
 
 
 
 
 
70
  }
71
+ }
72
+ }
73
+
74
+ loadAccounts();
75
+
76
+ return () => {
77
+ active = false;
78
  };
79
+ }, []);
80
+
81
+ const availableActions = useMemo(() => {
82
+ if (triggerType === "any_comment" || triggerType === "comment_keyword") {
83
+ return [
84
+ { value: "reply_comment", label: "Reply to Comment" },
85
+ { value: "send_dm", label: "Send Private Message" },
86
+ ];
87
+ }
88
+
89
+ return [
90
+ { value: "send_dm", label: "Send Private Message" },
91
+ ];
92
+ }, [triggerType]);
93
+
94
+ useEffect(() => {
95
+ if (!availableActions.some((action) => action.value === actionType)) {
96
+ setActionType(availableActions[0].value);
97
+ }
98
+ }, [actionType, availableActions]);
99
+
100
+ const handleSubmit = async () => {
101
+ if (!name || !responseTemplate) {
102
+ toast.error("Please fill the required fields");
103
+ return;
104
+ }
105
+
106
+ if (!isWhatsAppTrigger && !connectedAccountId) {
107
+ toast.error("Please select a connected account");
108
+ return;
109
+ }
110
+
111
+ setSubmitting(true);
112
+
113
+ try {
114
+ const response = await fetch("/api/social/automations/create", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ name,
119
+ triggerType,
120
+ keywords: keywords
121
+ .split(",")
122
+ .map((keyword) => keyword.trim())
123
+ .filter(Boolean),
124
+ actionType,
125
+ responseTemplate,
126
+ connectedAccountId: isWhatsAppTrigger ? null : connectedAccountId,
127
+ }),
128
+ });
129
+
130
+ const data = await response.json();
131
+
132
+ if (!response.ok) {
133
+ throw new Error(data.error || "Failed to create automation rule");
134
+ }
135
 
136
+ toast.success("Rule created successfully");
137
+ router.push("/dashboard/social/automations");
138
+ } catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ toast.error(message || "Failed to create rule");
141
+ } finally {
142
+ setSubmitting(false);
143
+ }
144
+ };
145
+
146
+ return (
147
+ <div className="max-w-2xl mx-auto p-6 space-y-6">
148
+ <div className="flex items-center gap-4">
149
+ <Button variant="ghost" size="icon" asChild>
150
+ <Link href="/dashboard/social/automations">
151
+ <ArrowLeft className="h-4 w-4" />
152
+ </Link>
153
+ </Button>
154
+ <h1 className="text-2xl font-bold">Create Automation Rule</h1>
155
+ </div>
156
+
157
+ <Card>
158
+ <CardHeader>
159
+ <CardTitle>Rule Configuration</CardTitle>
160
+ </CardHeader>
161
+ <CardContent className="space-y-4">
162
+ <div className="space-y-2">
163
+ <Label>Rule Name</Label>
164
+ <Input
165
+ placeholder="e.g. Pricing Auto-Reply"
166
+ value={name}
167
+ onChange={(event) => setName(event.target.value)}
168
+ />
169
+ </div>
170
+
171
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
172
+ <div className="space-y-2">
173
+ <Label>Trigger</Label>
174
+ <Select value={triggerType} onValueChange={setTriggerType}>
175
+ <SelectTrigger>
176
+ <SelectValue />
177
+ </SelectTrigger>
178
+ <SelectContent>
179
+ <SelectItem value="comment_keyword">Comment Keyword</SelectItem>
180
+ <SelectItem value="dm_keyword">DM Keyword</SelectItem>
181
+ <SelectItem value="story_mention">Story Mention</SelectItem>
182
+ <SelectItem value="any_comment">Any Comment</SelectItem>
183
+ <SelectItem value="whatsapp_keyword">WhatsApp Keyword</SelectItem>
184
+ <SelectItem value="whatsapp_command">WhatsApp Command</SelectItem>
185
+ </SelectContent>
186
+ </Select>
187
+ </div>
188
+
189
+ <div className="space-y-2">
190
+ <Label>Action</Label>
191
+ <Select value={actionType} onValueChange={setActionType}>
192
+ <SelectTrigger>
193
+ <SelectValue />
194
+ </SelectTrigger>
195
+ <SelectContent>
196
+ {availableActions.map((action) => (
197
+ <SelectItem key={action.value} value={action.value}>
198
+ {action.label}
199
+ </SelectItem>
200
+ ))}
201
+ </SelectContent>
202
+ </Select>
203
  </div>
204
+ </div>
205
+
206
+ {!isWhatsAppTrigger && (
207
+ <div className="space-y-2">
208
+ <Label>Connected Account</Label>
209
+ <Select
210
+ value={connectedAccountId}
211
+ onValueChange={setConnectedAccountId}
212
+ disabled={loadingAccounts || accounts.length === 0}
213
+ >
214
+ <SelectTrigger>
215
+ <SelectValue placeholder={loadingAccounts ? "Loading accounts..." : "Select an account"} />
216
+ </SelectTrigger>
217
+ <SelectContent>
218
+ {accounts.map((account) => (
219
+ <SelectItem key={account.id} value={account.id}>
220
+ {(account.name || account.providerAccountId) + ` (${account.provider})`}
221
+ </SelectItem>
222
+ ))}
223
+ </SelectContent>
224
+ </Select>
225
+ </div>
226
+ )}
227
+
228
+ {(triggerType === "comment_keyword" || triggerType === "dm_keyword" || triggerType === "whatsapp_keyword" || triggerType === "whatsapp_command") && (
229
+ <div className="space-y-2">
230
+ <Label>
231
+ {triggerType === "whatsapp_command"
232
+ ? "Commands (comma separated)"
233
+ : "Keywords (comma separated)"}
234
+ </Label>
235
+ <Input
236
+ placeholder={triggerType === "whatsapp_command" ? "/start, /menu" : "price, cost, how much"}
237
+ value={keywords}
238
+ onChange={(event) => setKeywords(event.target.value)}
239
+ />
240
+ </div>
241
+ )}
242
+
243
+ <div className="space-y-2">
244
+ <Label>Response Message</Label>
245
+ <Textarea
246
+ placeholder="Hi there! Here is the information you asked for..."
247
+ className="min-h-[120px]"
248
+ value={responseTemplate}
249
+ onChange={(event) => setResponseTemplate(event.target.value)}
250
+ />
251
+ </div>
252
 
253
+ <Button onClick={handleSubmit} className="w-full" disabled={submitting}>
254
+ {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
255
+ Create Rule
256
+ </Button>
257
+ </CardContent>
258
+ </Card>
259
+ </div>
260
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  }
app/dashboard/social/automations/page.tsx CHANGED
@@ -16,11 +16,23 @@ export default async function AutomationsPage() {
16
  const automations = await db.query.socialAutomations.findMany({
17
  where: eq(socialAutomations.userId, session.user.id),
18
  with: {
19
- // We haven't defined relation in schema relations.ts yet, so usually we'd fetch manually or define relations.
20
- // For now, let's just fetch plain. We might lack account name in UI.
21
  }
22
  });
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  return (
25
  <div className="flex flex-col gap-6 p-6">
26
  <div className="flex items-center justify-between">
@@ -33,7 +45,7 @@ export default async function AutomationsPage() {
33
  </Button>
34
  <h1 className="text-3xl font-bold tracking-tight">Social Automations</h1>
35
  <p className="text-muted-foreground">
36
- Automatically reply to comments and DMs based on keywords.
37
  </p>
38
  </div>
39
  <Button asChild>
@@ -49,9 +61,14 @@ export default async function AutomationsPage() {
49
  <CardHeader className="pb-3">
50
  <div className="flex justify-between items-start">
51
  <CardTitle className="text-lg">{auto.name}</CardTitle>
52
- <Badge variant={auto.isActive ? "default" : "secondary"}>
53
- {auto.isActive ? "Active" : "Paused"}
54
- </Badge>
 
 
 
 
 
55
  </div>
56
  <CardDescription>
57
  Trigger: <span className="font-mono text-xs">{auto.triggerType}</span>
@@ -61,8 +78,18 @@ export default async function AutomationsPage() {
61
  <div className="text-sm space-y-2">
62
  <div className="flex items-center gap-2 text-muted-foreground">
63
  <Zap className="h-3 w-3" />
64
- <span>Action: {auto.actionType === "reply_comment" ? "Reply to Comment" : "Send DM"}</span>
65
  </div>
 
 
 
 
 
 
 
 
 
 
66
  <div className="p-2 bg-muted rounded text-xs truncate">
67
  {auto.responseTemplate}
68
  </div>
 
16
  const automations = await db.query.socialAutomations.findMany({
17
  where: eq(socialAutomations.userId, session.user.id),
18
  with: {
19
+ account: true,
 
20
  }
21
  });
22
 
23
+ const getActionLabel = (actionType: string) => {
24
+ if (actionType === "reply_comment") return "Reply to Comment";
25
+ if (actionType === "send_dm") return "Send Direct Message";
26
+ if (actionType === "whatsapp_reply") return "Send WhatsApp Reply";
27
+ return actionType;
28
+ };
29
+
30
+ const getPlatformLabel = (triggerType: string, provider?: string | null) => {
31
+ if (provider) return provider;
32
+ if (triggerType.startsWith("whatsapp_")) return "whatsapp";
33
+ return "unassigned";
34
+ };
35
+
36
  return (
37
  <div className="flex flex-col gap-6 p-6">
38
  <div className="flex items-center justify-between">
 
45
  </Button>
46
  <h1 className="text-3xl font-bold tracking-tight">Social Automations</h1>
47
  <p className="text-muted-foreground">
48
+ Automatically handle comments, DMs, mentions, and WhatsApp replies.
49
  </p>
50
  </div>
51
  <Button asChild>
 
61
  <CardHeader className="pb-3">
62
  <div className="flex justify-between items-start">
63
  <CardTitle className="text-lg">{auto.name}</CardTitle>
64
+ <div className="flex items-center gap-2">
65
+ <Badge variant="outline" className="capitalize">
66
+ {getPlatformLabel(auto.triggerType, auto.account?.provider)}
67
+ </Badge>
68
+ <Badge variant={auto.isActive ? "default" : "secondary"}>
69
+ {auto.isActive ? "Active" : "Paused"}
70
+ </Badge>
71
+ </div>
72
  </div>
73
  <CardDescription>
74
  Trigger: <span className="font-mono text-xs">{auto.triggerType}</span>
 
78
  <div className="text-sm space-y-2">
79
  <div className="flex items-center gap-2 text-muted-foreground">
80
  <Zap className="h-3 w-3" />
81
+ <span>Action: {getActionLabel(auto.actionType)}</span>
82
  </div>
83
+ {auto.account?.name && (
84
+ <div className="text-xs text-muted-foreground">
85
+ Account: {auto.account.name}
86
+ </div>
87
+ )}
88
+ {auto.keywords && auto.keywords.length > 0 && (
89
+ <div className="text-xs text-muted-foreground">
90
+ Keywords: {auto.keywords.join(", ")}
91
+ </div>
92
+ )}
93
  <div className="p-2 bg-muted rounded text-xs truncate">
94
  {auto.responseTemplate}
95
  </div>
components/performance-dashboard.tsx CHANGED
@@ -1,21 +1,11 @@
1
  "use client";
2
 
3
- import React, { useEffect, useState } from "react";
4
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
6
  import { AlertCircle, CheckCircle, TrendingUp } from "lucide-react";
7
- import { assessWebVitals } from "@/lib/performance-monitoring";
8
 
9
- interface PerformanceMetrics {
10
- lcp: number;
11
- cls: number;
12
- fid: number;
13
- avgAPITime: number;
14
- slowRequests: number;
15
- cachedRequests: number;
16
- totalRequests: number;
17
- cacheHitRate: number;
18
- }
19
 
20
  interface WebVitalsStatus {
21
  lcp: { value: number; status: "good" | "needs-improvement" | "poor" };
@@ -23,21 +13,42 @@ interface WebVitalsStatus {
23
  fid: { value: number; status: "good" | "needs-improvement" | "poor" };
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
26
  export function PerformanceDashboard() {
27
- const [metrics, setMetrics] = useState<PerformanceMetrics | null>(null);
28
  const [vitalsStatus, setVitalsStatus] = useState<WebVitalsStatus | null>(null);
29
- const [apiMetrics, setApiMetrics] = useState<any[]>([]);
30
  const [loading, setLoading] = useState(true);
31
 
32
  useEffect(() => {
33
- // Fetch metrics from server
34
  async function fetchMetrics() {
35
  try {
36
  const response = await fetch("/api/performance/metrics");
37
- if (response.ok) {
38
- const data = await response.json();
39
- setMetrics(data);
40
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  } catch (error) {
42
  console.error("Failed to fetch metrics:", error);
43
  } finally {
@@ -45,16 +56,37 @@ export function PerformanceDashboard() {
45
  }
46
  }
47
 
48
- // Assess Web Vitals
49
- const vitals = assessWebVitals();
50
- setVitalsStatus(vitals as WebVitalsStatus);
51
- fetchMetrics();
 
 
52
 
53
- // Refresh metrics every 30 seconds
54
- const interval = setInterval(fetchMetrics, 30000);
55
  return () => clearInterval(interval);
56
  }, []);
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  const getStatusColor = (status: string) => {
59
  switch (status) {
60
  case "good":
@@ -70,23 +102,25 @@ export function PerformanceDashboard() {
70
 
71
  const getStatusIcon = (status: string) => {
72
  if (status === "good") {
73
- return <CheckCircle className="w-4 h-4 text-green-600" />;
74
  }
 
75
  if (status === "poor") {
76
- return <AlertCircle className="w-4 h-4 text-red-600" />;
77
  }
78
- return <TrendingUp className="w-4 h-4 text-yellow-600" />;
 
79
  };
80
 
81
  if (loading) {
82
  return (
83
  <div className="space-y-6">
84
  <div className="grid gap-4 md:grid-cols-3">
85
- {Array.from({ length: 3 }).map((_, i) => (
86
- <Card key={i}>
87
  <CardHeader className="space-y-2">
88
- <div className="h-4 w-24 bg-muted animate-pulse rounded" />
89
- <div className="h-8 w-32 bg-muted animate-pulse rounded" />
90
  </CardHeader>
91
  </Card>
92
  ))}
@@ -97,7 +131,6 @@ export function PerformanceDashboard() {
97
 
98
  return (
99
  <div className="space-y-6">
100
- {/* Web Vitals Summary */}
101
  <div className="grid gap-4 md:grid-cols-3">
102
  {vitalsStatus && (
103
  <>
@@ -109,12 +142,10 @@ export function PerformanceDashboard() {
109
  <CardContent>
110
  <div className="flex items-center justify-between">
111
  <div>
112
- <p className="text-2xl font-bold">
113
- {vitalsStatus.lcp.value.toFixed(0)}ms
114
- </p>
115
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.lcp.status)}`}>
116
  {vitalsStatus.lcp.status === "good"
117
- ? "Good (2500ms)"
118
  : vitalsStatus.lcp.status === "needs-improvement"
119
  ? "Needs Improvement (2500-4000ms)"
120
  : "Poor (>4000ms)"}
@@ -133,12 +164,10 @@ export function PerformanceDashboard() {
133
  <CardContent>
134
  <div className="flex items-center justify-between">
135
  <div>
136
- <p className="text-2xl font-bold">
137
- {vitalsStatus.cls.value.toFixed(3)}
138
- </p>
139
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.cls.status)}`}>
140
  {vitalsStatus.cls.status === "good"
141
- ? "Good (0.1)"
142
  : vitalsStatus.cls.status === "needs-improvement"
143
  ? "Needs Improvement (0.1-0.25)"
144
  : "Poor (>0.25)"}
@@ -157,12 +186,10 @@ export function PerformanceDashboard() {
157
  <CardContent>
158
  <div className="flex items-center justify-between">
159
  <div>
160
- <p className="text-2xl font-bold">
161
- {vitalsStatus.fid.value.toFixed(0)}ms
162
- </p>
163
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.fid.status)}`}>
164
  {vitalsStatus.fid.status === "good"
165
- ? "Good (100ms)"
166
  : vitalsStatus.fid.status === "needs-improvement"
167
  ? "Needs Improvement (100-300ms)"
168
  : "Poor (>300ms)"}
@@ -176,7 +203,6 @@ export function PerformanceDashboard() {
176
  )}
177
  </div>
178
 
179
- {/* API Performance */}
180
  {metrics && (
181
  <>
182
  <div className="grid gap-4 md:grid-cols-2">
@@ -187,9 +213,7 @@ export function PerformanceDashboard() {
187
  <CardContent className="space-y-4">
188
  <div>
189
  <p className="text-xs text-muted-foreground">Average Response Time</p>
190
- <p className="text-2xl font-bold">
191
- {metrics.avgAPITime.toFixed(0)}ms
192
- </p>
193
  </div>
194
  <div>
195
  <p className="text-xs text-muted-foreground">Slow Requests (&gt;1s)</p>
@@ -205,9 +229,7 @@ export function PerformanceDashboard() {
205
  <CardContent className="space-y-4">
206
  <div>
207
  <p className="text-xs text-muted-foreground">Cache Hit Rate</p>
208
- <p className="text-2xl font-bold">
209
- {metrics.cacheHitRate.toFixed(1)}%
210
- </p>
211
  </div>
212
  <div>
213
  <p className="text-xs text-muted-foreground">Cached Requests</p>
@@ -219,17 +241,15 @@ export function PerformanceDashboard() {
219
  </Card>
220
  </div>
221
 
222
- {/* API Performance Breakdown */}
223
  <Card>
224
  <CardHeader>
225
  <CardTitle>Request Distribution</CardTitle>
226
  <CardDescription>API endpoint response times distribution</CardDescription>
227
  </CardHeader>
228
  <CardContent>
229
- <div className="w-full h-80">
230
  <ResponsiveContainer width="100%" height="100%">
231
- <BarChart data={apiMetrics.slice(0, 10)}>
232
- <CartesianGrid strokeDasharray="3 3" />
233
  <XAxis dataKey="endpoint" angle={-45} textAnchor="end" height={80} />
234
  <YAxis />
235
  <Tooltip />
@@ -244,14 +264,13 @@ export function PerformanceDashboard() {
244
  </>
245
  )}
246
 
247
- {/* Performance Tips */}
248
  <Card>
249
  <CardHeader>
250
  <CardTitle className="text-sm font-medium">Performance Tips</CardTitle>
251
  </CardHeader>
252
  <CardContent className="space-y-3">
253
  <div className="flex gap-3">
254
- <CheckCircle className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
255
  <div>
256
  <p className="text-sm font-medium">Enable Caching</p>
257
  <p className="text-xs text-muted-foreground">
@@ -261,7 +280,7 @@ export function PerformanceDashboard() {
261
  </div>
262
 
263
  <div className="flex gap-3">
264
- <AlertCircle className="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
265
  <div>
266
  <p className="text-sm font-medium">Monitor Slow Requests</p>
267
  <p className="text-xs text-muted-foreground">
@@ -271,7 +290,7 @@ export function PerformanceDashboard() {
271
  </div>
272
 
273
  <div className="flex gap-3">
274
- <CheckCircle className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
275
  <div>
276
  <p className="text-sm font-medium">Bundle Analysis</p>
277
  <p className="text-xs text-muted-foreground">
 
1
  "use client";
2
 
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { Bar, BarChart, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
 
5
  import { AlertCircle, CheckCircle, TrendingUp } from "lucide-react";
 
6
 
7
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { assessWebVitals, type APIMetric, type PerformanceSummary } from "@/lib/performance-monitoring";
 
 
 
 
 
 
 
 
9
 
10
  interface WebVitalsStatus {
11
  lcp: { value: number; status: "good" | "needs-improvement" | "poor" };
 
13
  fid: { value: number; status: "good" | "needs-improvement" | "poor" };
14
  }
15
 
16
+ interface MetricsResponse extends PerformanceSummary {
17
+ apiMetrics: APIMetric[];
18
+ }
19
+
20
+ interface AggregatedApiMetric {
21
+ endpoint: string;
22
+ avgDuration: number;
23
+ maxDuration: number;
24
+ }
25
+
26
  export function PerformanceDashboard() {
27
+ const [metrics, setMetrics] = useState<PerformanceSummary | null>(null);
28
  const [vitalsStatus, setVitalsStatus] = useState<WebVitalsStatus | null>(null);
29
+ const [apiMetrics, setApiMetrics] = useState<APIMetric[]>([]);
30
  const [loading, setLoading] = useState(true);
31
 
32
  useEffect(() => {
 
33
  async function fetchMetrics() {
34
  try {
35
  const response = await fetch("/api/performance/metrics");
36
+ if (!response.ok) {
37
+ throw new Error("Failed to fetch performance metrics");
 
38
  }
39
+
40
+ const data: MetricsResponse = await response.json();
41
+ setMetrics({
42
+ lcp: data.lcp,
43
+ cls: data.cls,
44
+ fid: data.fid,
45
+ avgAPITime: data.avgAPITime,
46
+ slowRequests: data.slowRequests,
47
+ cachedRequests: data.cachedRequests,
48
+ totalRequests: data.totalRequests,
49
+ cacheHitRate: data.cacheHitRate,
50
+ });
51
+ setApiMetrics(data.apiMetrics || []);
52
  } catch (error) {
53
  console.error("Failed to fetch metrics:", error);
54
  } finally {
 
56
  }
57
  }
58
 
59
+ setVitalsStatus(assessWebVitals() as WebVitalsStatus);
60
+ void fetchMetrics();
61
+
62
+ const interval = setInterval(() => {
63
+ void fetchMetrics();
64
+ }, 30000);
65
 
 
 
66
  return () => clearInterval(interval);
67
  }, []);
68
 
69
+ const aggregatedApiMetrics = useMemo<AggregatedApiMetric[]>(() => {
70
+ const byEndpoint = new Map<string, { total: number; count: number; max: number }>();
71
+
72
+ for (const metric of apiMetrics) {
73
+ const current = byEndpoint.get(metric.endpoint) || { total: 0, count: 0, max: 0 };
74
+ current.total += metric.duration;
75
+ current.count += 1;
76
+ current.max = Math.max(current.max, metric.duration);
77
+ byEndpoint.set(metric.endpoint, current);
78
+ }
79
+
80
+ return Array.from(byEndpoint.entries())
81
+ .map(([endpoint, data]) => ({
82
+ endpoint,
83
+ avgDuration: data.total / data.count,
84
+ maxDuration: data.max,
85
+ }))
86
+ .sort((a, b) => b.avgDuration - a.avgDuration)
87
+ .slice(0, 10);
88
+ }, [apiMetrics]);
89
+
90
  const getStatusColor = (status: string) => {
91
  switch (status) {
92
  case "good":
 
102
 
103
  const getStatusIcon = (status: string) => {
104
  if (status === "good") {
105
+ return <CheckCircle className="h-4 w-4 text-green-600" />;
106
  }
107
+
108
  if (status === "poor") {
109
+ return <AlertCircle className="h-4 w-4 text-red-600" />;
110
  }
111
+
112
+ return <TrendingUp className="h-4 w-4 text-yellow-600" />;
113
  };
114
 
115
  if (loading) {
116
  return (
117
  <div className="space-y-6">
118
  <div className="grid gap-4 md:grid-cols-3">
119
+ {Array.from({ length: 3 }).map((_, index) => (
120
+ <Card key={index}>
121
  <CardHeader className="space-y-2">
122
+ <div className="h-4 w-24 animate-pulse rounded bg-muted" />
123
+ <div className="h-8 w-32 animate-pulse rounded bg-muted" />
124
  </CardHeader>
125
  </Card>
126
  ))}
 
131
 
132
  return (
133
  <div className="space-y-6">
 
134
  <div className="grid gap-4 md:grid-cols-3">
135
  {vitalsStatus && (
136
  <>
 
142
  <CardContent>
143
  <div className="flex items-center justify-between">
144
  <div>
145
+ <p className="text-2xl font-bold">{vitalsStatus.lcp.value.toFixed(0)}ms</p>
 
 
146
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.lcp.status)}`}>
147
  {vitalsStatus.lcp.status === "good"
148
+ ? "Good (<=2500ms)"
149
  : vitalsStatus.lcp.status === "needs-improvement"
150
  ? "Needs Improvement (2500-4000ms)"
151
  : "Poor (>4000ms)"}
 
164
  <CardContent>
165
  <div className="flex items-center justify-between">
166
  <div>
167
+ <p className="text-2xl font-bold">{vitalsStatus.cls.value.toFixed(3)}</p>
 
 
168
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.cls.status)}`}>
169
  {vitalsStatus.cls.status === "good"
170
+ ? "Good (<=0.1)"
171
  : vitalsStatus.cls.status === "needs-improvement"
172
  ? "Needs Improvement (0.1-0.25)"
173
  : "Poor (>0.25)"}
 
186
  <CardContent>
187
  <div className="flex items-center justify-between">
188
  <div>
189
+ <p className="text-2xl font-bold">{vitalsStatus.fid.value.toFixed(0)}ms</p>
 
 
190
  <p className={`text-xs font-medium ${getStatusColor(vitalsStatus.fid.status)}`}>
191
  {vitalsStatus.fid.status === "good"
192
+ ? "Good (<=100ms)"
193
  : vitalsStatus.fid.status === "needs-improvement"
194
  ? "Needs Improvement (100-300ms)"
195
  : "Poor (>300ms)"}
 
203
  )}
204
  </div>
205
 
 
206
  {metrics && (
207
  <>
208
  <div className="grid gap-4 md:grid-cols-2">
 
213
  <CardContent className="space-y-4">
214
  <div>
215
  <p className="text-xs text-muted-foreground">Average Response Time</p>
216
+ <p className="text-2xl font-bold">{metrics.avgAPITime.toFixed(0)}ms</p>
 
 
217
  </div>
218
  <div>
219
  <p className="text-xs text-muted-foreground">Slow Requests (&gt;1s)</p>
 
229
  <CardContent className="space-y-4">
230
  <div>
231
  <p className="text-xs text-muted-foreground">Cache Hit Rate</p>
232
+ <p className="text-2xl font-bold">{metrics.cacheHitRate.toFixed(1)}%</p>
 
 
233
  </div>
234
  <div>
235
  <p className="text-xs text-muted-foreground">Cached Requests</p>
 
241
  </Card>
242
  </div>
243
 
 
244
  <Card>
245
  <CardHeader>
246
  <CardTitle>Request Distribution</CardTitle>
247
  <CardDescription>API endpoint response times distribution</CardDescription>
248
  </CardHeader>
249
  <CardContent>
250
+ <div className="h-80 w-full">
251
  <ResponsiveContainer width="100%" height="100%">
252
+ <BarChart data={aggregatedApiMetrics}>
 
253
  <XAxis dataKey="endpoint" angle={-45} textAnchor="end" height={80} />
254
  <YAxis />
255
  <Tooltip />
 
264
  </>
265
  )}
266
 
 
267
  <Card>
268
  <CardHeader>
269
  <CardTitle className="text-sm font-medium">Performance Tips</CardTitle>
270
  </CardHeader>
271
  <CardContent className="space-y-3">
272
  <div className="flex gap-3">
273
+ <CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
274
  <div>
275
  <p className="text-sm font-medium">Enable Caching</p>
276
  <p className="text-xs text-muted-foreground">
 
280
  </div>
281
 
282
  <div className="flex gap-3">
283
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-600" />
284
  <div>
285
  <p className="text-sm font-medium">Monitor Slow Requests</p>
286
  <p className="text-xs text-muted-foreground">
 
290
  </div>
291
 
292
  <div className="flex gap-3">
293
+ <CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
294
  <div>
295
  <p className="text-sm font-medium">Bundle Analysis</p>
296
  <p className="text-xs text-muted-foreground">
components/settings/social-settings.tsx CHANGED
@@ -306,7 +306,7 @@ export function SocialSettings({ connectedAccounts, automations = [] }: SocialSe
306
  </CardDescription>
307
  </div>
308
  <Button asChild>
309
- <Link href="/dashboard/social/create-automation">
310
  <Plus className="h-4 w-4 mr-2" />
311
  Add Rule
312
  </Link>
 
306
  </CardDescription>
307
  </div>
308
  <Button asChild>
309
+ <Link href="/dashboard/social/automations/new">
310
  <Plus className="h-4 w-4 mr-2" />
311
  Add Rule
312
  </Link>
lib/auth.ts CHANGED
@@ -123,7 +123,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
123
  if (!phoneNumber || !code) return null;
124
 
125
  // Import dynamically to avoid circular deps if any
126
- const { redis } = await import("@/lib/redis");
 
127
 
128
  // Verify Code
129
  if (!redis) {
 
123
  if (!phoneNumber || !code) return null;
124
 
125
  // Import dynamically to avoid circular deps if any
126
+ const { getRedis } = await import("@/lib/redis");
127
+ const redis = getRedis();
128
 
129
  // Verify Code
130
  if (!redis) {
lib/cache-manager.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { redis } from "@/lib/redis";
2
  import { Logger } from "@/lib/logger";
3
 
4
  /**
@@ -13,6 +13,8 @@ export async function getCached<T>(
13
  fetcher: () => Promise<T>,
14
  ttl = 300
15
  ): Promise<T> {
 
 
16
  if (!redis) {
17
  return fetcher(); // No Redis, always fetch fresh
18
  }
@@ -42,6 +44,8 @@ export async function getCached<T>(
42
  * @param pattern - Pattern to match (e.g., "businesses:user_123:*")
43
  */
44
  export async function invalidateCache(pattern: string) {
 
 
45
  if (!redis) return;
46
 
47
  try {
@@ -69,6 +73,8 @@ export async function setCache<T>(
69
  data: T,
70
  ttl = 300
71
  ): Promise<void> {
 
 
72
  if (!redis) return;
73
 
74
  try {
@@ -88,6 +94,8 @@ export async function setCache<T>(
88
  * @returns Cached data or null
89
  */
90
  export async function getCache<T>(key: string): Promise<T | null> {
 
 
91
  if (!redis) return null;
92
 
93
  try {
@@ -107,6 +115,8 @@ export async function getCache<T>(key: string): Promise<T | null> {
107
  * @param key - Cache key to delete
108
  */
109
  export async function deleteCache(key: string): Promise<void> {
 
 
110
  if (!redis) return;
111
 
112
  try {
 
1
+ import { getRedis } from "@/lib/redis";
2
  import { Logger } from "@/lib/logger";
3
 
4
  /**
 
13
  fetcher: () => Promise<T>,
14
  ttl = 300
15
  ): Promise<T> {
16
+ const redis = getRedis();
17
+
18
  if (!redis) {
19
  return fetcher(); // No Redis, always fetch fresh
20
  }
 
44
  * @param pattern - Pattern to match (e.g., "businesses:user_123:*")
45
  */
46
  export async function invalidateCache(pattern: string) {
47
+ const redis = getRedis();
48
+
49
  if (!redis) return;
50
 
51
  try {
 
73
  data: T,
74
  ttl = 300
75
  ): Promise<void> {
76
+ const redis = getRedis();
77
+
78
  if (!redis) return;
79
 
80
  try {
 
94
  * @returns Cached data or null
95
  */
96
  export async function getCache<T>(key: string): Promise<T | null> {
97
+ const redis = getRedis();
98
+
99
  if (!redis) return null;
100
 
101
  try {
 
115
  * @param key - Cache key to delete
116
  */
117
  export async function deleteCache(key: string): Promise<void> {
118
+ const redis = getRedis();
119
+
120
  if (!redis) return;
121
 
122
  try {
lib/environment-config.ts CHANGED
@@ -101,7 +101,7 @@ export function isDevelopment(): boolean {
101
  */
102
  export function isFeatureEnabled(featureName: string): boolean {
103
  const env = getEnv();
104
- const value = (env as Record<string, any>)[featureName];
105
  // Validate on import
106
  if (typeof window === "undefined") {
107
  try {
 
101
  */
102
  export function isFeatureEnabled(featureName: string): boolean {
103
  const env = getEnv();
104
+ const value = env[featureName as keyof Environment] as unknown;
105
  // Validate on import
106
  if (typeof window === "undefined") {
107
  try {
lib/performance-monitoring.ts CHANGED
@@ -2,20 +2,15 @@
2
 
3
  import { useEffect, useState } from "react";
4
 
5
- /**
6
- * Performance Monitoring Utilities
7
- * Tracks Web Vitals (LCP, FID, CLS) and API response times
8
- */
9
-
10
  interface PerformanceMetric {
11
  name: string;
12
  value: number;
13
  unit: string;
14
  timestamp: number;
15
- context?: Record<string, any>;
16
  }
17
 
18
- interface APIMetric {
19
  endpoint: string;
20
  method: string;
21
  duration: number;
@@ -24,127 +19,139 @@ interface APIMetric {
24
  cached?: boolean;
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  class PerformanceMonitor {
28
  private metrics: PerformanceMetric[] = [];
29
  private apiMetrics: APIMetric[] = [];
30
  private isEnabled = typeof window !== "undefined";
31
 
32
- /**
33
- * Initialize Web Vitals tracking
34
- * Measures: LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift)
35
- */
36
  initWebVitals() {
37
- if (!this.isEnabled) return;
38
-
39
- // Track LCP (Largest Contentful Paint)
40
- const observer = new PerformanceObserver((list) => {
41
- for (const entry of list.getEntries()) {
42
- if (entry.entryType === "largest-contentful-paint") {
43
- this.recordMetric({
44
- name: "LCP",
45
- value: entry.startTime,
46
- unit: "ms",
47
- timestamp: Date.now(),
48
- context: {
49
- element: (entry as any).element?.tagName,
50
- url: (entry as any).url,
51
- },
52
- });
53
  }
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
  });
56
 
57
- observer.observe({ entryTypes: ["largest-contentful-paint"] });
58
 
59
- // Track CLS (Cumulative Layout Shift)
60
  let clsValue = 0;
61
  const clsObserver = new PerformanceObserver((list) => {
62
- for (const entry of list.getEntries()) {
63
- if (!(entry as any).hadRecentInput) {
64
- clsValue += (entry as any).value;
65
- this.recordMetric({
66
- name: "CLS",
67
- value: clsValue,
68
- unit: "score",
69
- timestamp: Date.now(),
70
- });
71
  }
 
 
 
 
 
 
 
 
72
  }
73
  });
74
 
75
  clsObserver.observe({ entryTypes: ["layout-shift"] });
76
 
77
- // Track FID (First Input Delay)
78
  const fidObserver = new PerformanceObserver((list) => {
79
- for (const entry of list.getEntries()) {
80
- if (entry.entryType === "first-input") {
81
- this.recordMetric({
82
- name: "FID",
83
- value: (entry as any).processingDuration,
84
- unit: "ms",
85
- timestamp: Date.now(),
86
- });
87
  }
 
 
 
 
 
 
 
88
  }
89
  });
90
 
91
  fidObserver.observe({ entryTypes: ["first-input"] });
92
  }
93
 
94
- /**
95
- * Track API request performance
96
- * Called from API interceptor or fetch wrapper
97
- */
98
  recordAPIMetric(metric: APIMetric) {
99
  this.apiMetrics.push(metric);
100
 
101
- // Keep only last 100 metrics in memory
102
  if (this.apiMetrics.length > 100) {
103
  this.apiMetrics.shift();
104
  }
105
 
106
- // Send slow requests (>1s) to monitoring service
107
  if (metric.duration > 1000) {
108
  this.reportSlowRequest(metric);
109
  }
110
  }
111
 
112
- /**
113
- * Record custom performance metric
114
- */
115
  recordMetric(metric: PerformanceMetric) {
116
  this.metrics.push(metric);
117
 
118
- // Keep only last 100 metrics in memory
119
  if (this.metrics.length > 100) {
120
  this.metrics.shift();
121
  }
122
 
123
- // Log metrics that exceed thresholds
124
  if (metric.name === "LCP" && metric.value > 2500) {
125
  console.warn(`LCP exceeded threshold: ${metric.value.toFixed(2)}ms`);
126
  }
 
127
  if (metric.name === "CLS" && metric.value > 0.1) {
128
  console.warn(`CLS exceeded threshold: ${metric.value.toFixed(3)}`);
129
  }
130
  }
131
 
132
- /**
133
- * Get performance summary for dashboard
134
- */
135
- getSummary() {
136
- const lcp = this.metrics.find((m) => m.name === "LCP");
137
- const cls = this.metrics.find((m) => m.name === "CLS");
138
- const fid = this.metrics.find((m) => m.name === "FID");
139
 
140
  const avgAPITime =
141
  this.apiMetrics.length > 0
142
- ? this.apiMetrics.reduce((sum, m) => sum + m.duration, 0) /
143
- this.apiMetrics.length
144
  : 0;
145
 
146
- const slowRequests = this.apiMetrics.filter((m) => m.duration > 1000).length;
147
- const cachedRequests = this.apiMetrics.filter((m) => m.cached).length;
148
 
149
  return {
150
  lcp: lcp?.value || 0,
@@ -161,35 +168,15 @@ class PerformanceMonitor {
161
  };
162
  }
163
 
164
- /**
165
- * Get all API metrics for analytics
166
- */
167
  getAPIMetrics() {
168
  return [...this.apiMetrics];
169
  }
170
 
171
- /**
172
- * Report slow or failed requests to monitoring service
173
- */
174
- private reportSlowRequest(metric: APIMetric) {
175
- // In production, send to Sentry or performance monitoring service
176
- if (typeof window !== "undefined" && (window as any).__SENTRY__) {
177
- // Sentry integration point
178
- console.debug("Slow API request detected:", metric);
179
- }
180
- }
181
-
182
- /**
183
- * Clear metrics (useful for testing or reset)
184
- */
185
  clearMetrics() {
186
  this.metrics = [];
187
  this.apiMetrics = [];
188
  }
189
 
190
- /**
191
- * Export metrics for analysis
192
- */
193
  exportMetrics() {
194
  return {
195
  webVitals: this.metrics,
@@ -198,37 +185,38 @@ class PerformanceMonitor {
198
  timestamp: Date.now(),
199
  };
200
  }
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
 
203
- // Singleton instance
204
  export const performanceMonitor = new PerformanceMonitor();
205
 
206
- /**
207
- * Hook to use performance monitoring in React components
208
- */
209
  export function usePerformanceMonitoring() {
210
- const [metrics, setMetrics] = useState<any>(null);
211
 
212
  useEffect(() => {
213
  performanceMonitor.initWebVitals();
214
 
215
- // Update metrics every 5 seconds
216
  const interval = setInterval(() => {
217
  setMetrics(performanceMonitor.getSummary());
218
  }, 5000);
219
 
220
- // Initial read
221
- setMetrics(performanceMonitor.getSummary());
222
-
223
  return () => clearInterval(interval);
224
  }, []);
225
 
226
  return metrics;
227
  }
228
 
229
- /**
230
- * Wrapper for fetch to automatically track performance
231
- */
232
  export async function fetchWithMetrics(
233
  url: string,
234
  options?: RequestInit & { cached?: boolean }
@@ -266,10 +254,6 @@ export async function fetchWithMetrics(
266
  }
267
  }
268
 
269
- /**
270
- * Get Core Web Vitals assessment
271
- * Returns: Good, Needs Improvement, or Poor
272
- */
273
  export function assessWebVitals() {
274
  const summary = performanceMonitor.getSummary();
275
 
@@ -288,4 +272,3 @@ export function assessWebVitals() {
288
  },
289
  };
290
  }
291
-
 
2
 
3
  import { useEffect, useState } from "react";
4
 
 
 
 
 
 
5
  interface PerformanceMetric {
6
  name: string;
7
  value: number;
8
  unit: string;
9
  timestamp: number;
10
+ context?: Record<string, unknown>;
11
  }
12
 
13
+ export interface APIMetric {
14
  endpoint: string;
15
  method: string;
16
  duration: number;
 
19
  cached?: boolean;
20
  }
21
 
22
+ export interface PerformanceSummary {
23
+ lcp: number;
24
+ cls: number;
25
+ fid: number;
26
+ avgAPITime: number;
27
+ slowRequests: number;
28
+ cachedRequests: number;
29
+ totalRequests: number;
30
+ cacheHitRate: number;
31
+ }
32
+
33
+ interface LargestContentfulPaintEntry extends PerformanceEntry {
34
+ element?: Element | null;
35
+ url?: string;
36
+ }
37
+
38
+ interface LayoutShiftEntry extends PerformanceEntry {
39
+ hadRecentInput: boolean;
40
+ value: number;
41
+ }
42
+
43
+ interface FirstInputEntry extends PerformanceEntry {
44
+ processingStart: number;
45
+ }
46
+
47
  class PerformanceMonitor {
48
  private metrics: PerformanceMetric[] = [];
49
  private apiMetrics: APIMetric[] = [];
50
  private isEnabled = typeof window !== "undefined";
51
 
 
 
 
 
52
  initWebVitals() {
53
+ if (!this.isEnabled || typeof PerformanceObserver === "undefined") {
54
+ return;
55
+ }
56
+
57
+ const lcpObserver = new PerformanceObserver((list) => {
58
+ for (const entry of list.getEntries() as LargestContentfulPaintEntry[]) {
59
+ if (entry.entryType !== "largest-contentful-paint") {
60
+ continue;
 
 
 
 
 
 
 
 
61
  }
62
+
63
+ this.recordMetric({
64
+ name: "LCP",
65
+ value: entry.startTime,
66
+ unit: "ms",
67
+ timestamp: Date.now(),
68
+ context: {
69
+ element: entry.element?.tagName,
70
+ url: entry.url,
71
+ },
72
+ });
73
  }
74
  });
75
 
76
+ lcpObserver.observe({ entryTypes: ["largest-contentful-paint"] });
77
 
 
78
  let clsValue = 0;
79
  const clsObserver = new PerformanceObserver((list) => {
80
+ for (const entry of list.getEntries() as LayoutShiftEntry[]) {
81
+ if (entry.hadRecentInput) {
82
+ continue;
 
 
 
 
 
 
83
  }
84
+
85
+ clsValue += entry.value;
86
+ this.recordMetric({
87
+ name: "CLS",
88
+ value: clsValue,
89
+ unit: "score",
90
+ timestamp: Date.now(),
91
+ });
92
  }
93
  });
94
 
95
  clsObserver.observe({ entryTypes: ["layout-shift"] });
96
 
 
97
  const fidObserver = new PerformanceObserver((list) => {
98
+ for (const entry of list.getEntries() as FirstInputEntry[]) {
99
+ if (entry.entryType !== "first-input") {
100
+ continue;
 
 
 
 
 
101
  }
102
+
103
+ this.recordMetric({
104
+ name: "FID",
105
+ value: entry.processingStart - entry.startTime,
106
+ unit: "ms",
107
+ timestamp: Date.now(),
108
+ });
109
  }
110
  });
111
 
112
  fidObserver.observe({ entryTypes: ["first-input"] });
113
  }
114
 
 
 
 
 
115
  recordAPIMetric(metric: APIMetric) {
116
  this.apiMetrics.push(metric);
117
 
 
118
  if (this.apiMetrics.length > 100) {
119
  this.apiMetrics.shift();
120
  }
121
 
 
122
  if (metric.duration > 1000) {
123
  this.reportSlowRequest(metric);
124
  }
125
  }
126
 
 
 
 
127
  recordMetric(metric: PerformanceMetric) {
128
  this.metrics.push(metric);
129
 
 
130
  if (this.metrics.length > 100) {
131
  this.metrics.shift();
132
  }
133
 
 
134
  if (metric.name === "LCP" && metric.value > 2500) {
135
  console.warn(`LCP exceeded threshold: ${metric.value.toFixed(2)}ms`);
136
  }
137
+
138
  if (metric.name === "CLS" && metric.value > 0.1) {
139
  console.warn(`CLS exceeded threshold: ${metric.value.toFixed(3)}`);
140
  }
141
  }
142
 
143
+ getSummary(): PerformanceSummary {
144
+ const lcp = this.metrics.find((metric) => metric.name === "LCP");
145
+ const cls = this.metrics.find((metric) => metric.name === "CLS");
146
+ const fid = this.metrics.find((metric) => metric.name === "FID");
 
 
 
147
 
148
  const avgAPITime =
149
  this.apiMetrics.length > 0
150
+ ? this.apiMetrics.reduce((sum, metric) => sum + metric.duration, 0) / this.apiMetrics.length
 
151
  : 0;
152
 
153
+ const slowRequests = this.apiMetrics.filter((metric) => metric.duration > 1000).length;
154
+ const cachedRequests = this.apiMetrics.filter((metric) => metric.cached).length;
155
 
156
  return {
157
  lcp: lcp?.value || 0,
 
168
  };
169
  }
170
 
 
 
 
171
  getAPIMetrics() {
172
  return [...this.apiMetrics];
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  clearMetrics() {
176
  this.metrics = [];
177
  this.apiMetrics = [];
178
  }
179
 
 
 
 
180
  exportMetrics() {
181
  return {
182
  webVitals: this.metrics,
 
185
  timestamp: Date.now(),
186
  };
187
  }
188
+
189
+ private reportSlowRequest(metric: APIMetric) {
190
+ if (typeof window === "undefined") {
191
+ return;
192
+ }
193
+
194
+ const sentryWindow = window as Window & { __SENTRY__?: unknown };
195
+
196
+ if (sentryWindow.__SENTRY__) {
197
+ console.debug("Slow API request detected:", metric);
198
+ }
199
+ }
200
  }
201
 
 
202
  export const performanceMonitor = new PerformanceMonitor();
203
 
 
 
 
204
  export function usePerformanceMonitoring() {
205
+ const [metrics, setMetrics] = useState<PerformanceSummary>(() => performanceMonitor.getSummary());
206
 
207
  useEffect(() => {
208
  performanceMonitor.initWebVitals();
209
 
 
210
  const interval = setInterval(() => {
211
  setMetrics(performanceMonitor.getSummary());
212
  }, 5000);
213
 
 
 
 
214
  return () => clearInterval(interval);
215
  }, []);
216
 
217
  return metrics;
218
  }
219
 
 
 
 
220
  export async function fetchWithMetrics(
221
  url: string,
222
  options?: RequestInit & { cached?: boolean }
 
254
  }
255
  }
256
 
 
 
 
 
257
  export function assessWebVitals() {
258
  const summary = performanceMonitor.getSummary();
259
 
 
272
  },
273
  };
274
  }
 
lib/queue.ts CHANGED
@@ -1,25 +1,22 @@
1
- // TypeScript ESLint configuration overrides for necessary 'any' types
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
- import { Queue, Worker, Job } from "bullmq";
4
- import { redis as connection } from "./redis";
5
- import Redis from "ioredis";
6
- import { db } from "@/db";
7
- import { emailLogs, businesses, emailTemplates, users } from "@/db/schema";
8
- import { eq, sql, and, gte, lt } from "drizzle-orm";
9
- import { interpolateTemplate, sendColdEmail } from "./email";
10
- import type { ScraperSourceName } from "./scrapers/types";
11
-
12
- // Ensure we have a valid Redis instance for BullMQ (even if disconnected/null in lib/redis)
13
- const safeConnection = connection || new Redis({
14
- maxRetriesPerRequest: null,
15
- lazyConnect: true
16
- });
17
 
18
- // Email queue
19
- export const emailQueue = new Queue("email-outreach", { connection: safeConnection as any });
20
-
21
- // Scraping queue
22
- export const scrapingQueue = new Queue("google-maps-scraping", { connection: safeConnection as any });
 
 
 
 
 
 
 
 
 
23
 
24
  interface EmailJobData {
25
  userId: string;
@@ -37,28 +34,89 @@ interface ScrapingJobData {
37
  sources?: ScraperSourceName[];
38
  }
39
 
40
- /**
41
- * Add email to queue
42
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  export async function queueEmail(data: EmailJobData) {
44
- await emailQueue.add(
45
- "send-email",
46
- data,
47
- {
48
- attempts: 3,
49
- backoff: {
50
- type: "exponential",
51
- delay: 2000,
52
- },
53
- }
54
- );
55
  }
56
 
57
- /**
58
- * Add scraping job to queue
59
- */
60
  export async function queueScraping(params: ScrapingJobData) {
61
- await scrapingQueue.add("scrape-google-maps", params, {
62
  attempts: 3,
63
  backoff: { type: "exponential", delay: 2000 },
64
  removeOnComplete: { count: 100 },
@@ -66,602 +124,558 @@ export async function queueScraping(params: ScrapingJobData) {
66
  });
67
  }
68
 
69
- /**
70
- * Email worker - processes email sending jobs
71
- */
72
- export const emailWorker = new Worker(
73
- "email-outreach",
74
- async (job: Job<EmailJobData>) => {
75
- const { userId, businessId, templateId, accessToken } = job.data;
76
 
77
- try {
78
- // Get business details
79
- const [business] = await db
80
- .select()
81
- .from(businesses)
82
- .where(eq(businesses.id, businessId))
83
- .limit(1);
84
 
85
- if (!business) {
86
- throw new Error("Business not found");
87
- }
88
 
89
- // Get template
90
- const [template] = await db
91
- .select()
92
- .from(emailTemplates)
93
- .where(eq(emailTemplates.id, templateId))
94
- .limit(1);
 
 
 
 
 
95
 
96
- if (!template) {
97
- throw new Error("Template not found");
98
- }
99
- // Check daily limit (50 emails/day)
100
- const startOfDay = new Date();
101
- startOfDay.setHours(0, 0, 0, 0);
102
-
103
- const endOfDay = new Date();
104
- endOfDay.setHours(23, 59, 59, 999);
105
-
106
- const [usage] = await db
107
- .select({ count: sql<number>`count(*)` })
108
- .from(emailLogs)
109
- .where(
110
- and(
111
- eq(emailLogs.userId, userId),
112
- eq(emailLogs.status, "sent"),
113
- gte(emailLogs.sentAt, startOfDay),
114
- lt(emailLogs.sentAt, endOfDay)
115
- )
116
- );
117
-
118
- if (usage && usage.count >= 50) {
119
- // Calculate time until next day
120
- const now = new Date();
121
- const tomorrow = new Date(now);
122
- tomorrow.setDate(tomorrow.getDate() + 1);
123
- tomorrow.setHours(0, 0, 0, 0);
124
- const delay = tomorrow.getTime() - now.getTime() + 60000; // 1 min buffer
125
-
126
- console.log(`⚠️ Daily email limit reached (${usage.count}/50). Delaying job ${job.id} by ${Math.round(delay / 1000 / 60)} minutes.`);
127
-
128
- await job.moveToDelayed(Date.now() + delay, job.token);
129
- return { delayed: true, reason: "Daily limit reached" };
130
- }
131
 
132
- const sender = await db.query.users.findFirst({
133
- where: eq(users.id, userId),
134
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- if (!sender) throw new Error("Sender not found");
 
 
137
 
138
- // Send emails
139
- // Send email
140
- // Log email immediately to get the ID for tracking
141
- const [logEntry] = await db.insert(emailLogs).values({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  userId,
143
  businessId: business.id,
144
  templateId: template.id,
145
  subject: interpolateTemplate(template.subject, business, sender),
146
- body: "", // Will update after sending
147
  status: "pending",
148
  errorMessage: null,
149
  sentAt: null,
150
- }).returning();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- const trackingDomain = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
153
 
154
- // Inject Open Tracking Pixel
155
- const pixelUrl = `${trackingDomain}/api/tracking/open?id=${logEntry.id}`;
156
- const trackingPixel = `<img src="${pixelUrl}" alt="" width="1" height="1" style="display:none;" />`;
157
 
158
- let finalBody = interpolateTemplate(template.body, business, sender);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
- // Inject Click Tracking (Basic implementation: rewrite hrefs)
161
- finalBody = finalBody.replace(/href=["']([^"']+)["']/g, (match, url) => {
162
- const encodedUrl = encodeURIComponent(url);
163
- const trackingUrl = `${trackingDomain}/api/tracking/click?id=${logEntry.id}&url=${encodedUrl}`;
164
- return `href="${trackingUrl}"`;
165
- });
 
 
 
 
 
 
 
166
 
167
- // Append pixel
168
- finalBody += trackingPixel;
 
169
 
170
- // Update body in log
171
- await db.update(emailLogs).set({ body: finalBody }).where(eq(emailLogs.id, logEntry.id));
 
172
 
173
- // Rename destructured variables to avoid conflict or use different scope
174
- const emailResult = await sendColdEmail(
175
- business,
176
- template,
177
- accessToken,
178
- sender
179
- );
180
 
181
- const success = emailResult.success;
182
- const error = emailResult.error;
 
 
 
 
 
 
 
 
183
 
184
- // Update log status
185
- await db.update(emailLogs).set({
186
- status: success ? "sent" : "failed",
187
- errorMessage: error,
188
- sentAt: success ? new Date() : null
189
- }).where(eq(emailLogs.id, logEntry.id));
190
-
191
- // Update business status
192
- await db
193
- .update(businesses)
194
- .set({
195
- emailSent: true,
196
- emailSentAt: new Date(),
197
- emailStatus: success ? "sent" : "failed",
198
- updatedAt: new Date(),
199
- })
200
- .where(eq(businesses.id, business.id));
201
-
202
- if (!success) {
203
- throw new Error(error || "Failed to send email");
204
- }
205
 
206
- return { success: true, businessId };
207
- } catch (error: unknown) {
208
- // Log failure
209
- const errorMessage = error instanceof Error ? error.message : String(error);
210
- await db.insert(emailLogs).values({
211
- userId,
212
- businessId,
213
- templateId,
214
- subject: "Cold Outreach",
215
- body: "Failed to send",
216
- status: "failed",
217
- errorMessage,
218
- });
219
 
220
- throw new Error(errorMessage);
221
- }
222
- },
223
- { connection: safeConnection as any }
224
- );
225
-
226
- /**
227
- * Scraping worker - processes Google Maps and Google Search scraping jobs
228
- */
229
- export const scrapingWorker = new Worker(
230
- "google-maps-scraping",
231
- async (job: Job<ScrapingJobData>) => {
232
- const { userId, jobId, keywords, location, sources = ["google-maps"] } = job.data;
233
- console.log(`\n🚀 Starting scraping job: ${job.id}`);
234
- console.log(` Job ID: ${jobId}`);
235
-
236
- // Import scraping jobs schema and dependencies
237
- const { scrapingJobs } = await import("@/db/schema");
238
- const { eq } = await import("drizzle-orm");
239
- const { scrapeMultiSource } = await import("./scrapers");
240
 
241
- try {
242
- // Find the scraping job
243
- let [jobRecord] = await db
244
  .select()
245
  .from(scrapingJobs)
246
  .where(eq(scrapingJobs.id, jobId))
247
  .limit(1);
248
 
249
- if (!jobRecord) throw new Error(`Job ${jobId} not found`);
250
-
251
- // Update to running status
252
- await db
253
- .update(scrapingJobs)
254
- .set({ status: "running" })
255
- .where(eq(scrapingJobs.id, jobId));
256
-
257
- console.log(` ✓ Status updated to running`);
258
-
259
- // Indefinite Loop State
260
- let totalFound = jobRecord.businessesFound || 0;
261
- let loopCount = 0;
262
- const MAX_LOOPS = 50; // Safety cap to prevent infinite billing/resource usage for now (user can restart)
263
 
264
- // Indefinite scraping loop
265
- while (true) {
266
- loopCount++;
 
267
 
268
- // 1. Check current status from DB (in case user paused/stopped)
269
- [jobRecord] = await db
270
- .select()
271
- .from(scrapingJobs)
272
- .where(eq(scrapingJobs.id, jobId))
273
- .limit(1);
274
 
275
- if (!jobRecord) break; // Should not happen
 
276
 
277
- // Handle Control Signals
278
- if (jobRecord.status === "paused") {
279
- console.log(` ⏸️ Job paused by user. Waiting...`);
280
- // Wait for 5 seconds then check again
281
- await new Promise(resolve => setTimeout(resolve, 5000));
282
- continue;
283
- }
 
 
284
 
285
- if (jobRecord.status === "failed" || jobRecord.status === "completed") {
286
- console.log(` 🛑 Job stopped by user (Status: ${jobRecord.status})`);
287
- break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
 
290
- // 2. Perform Scraping
291
- console.log(` 🔄 Loop ${loopCount}: Scraping...`);
292
-
293
- // Add random jitter to avoid patterns
294
- const jitter = Math.floor(Math.random() * 5000);
295
- await new Promise(resolve => setTimeout(resolve, 2000 + jitter));
296
-
297
- const results = await scrapeMultiSource(
298
- {
299
- keywords, // Improvement: Rotate keywords or add pagination in future
300
- location,
301
- limit: 20, // Smaller batch per loop
302
- sources
303
- },
304
- userId
305
- );
306
-
307
- // 3. Save Results
308
- if (results.length > 0) {
309
- console.log(` 💾 Saving ${results.length} businesses...`);
310
-
311
- // Map results to match DB schema (add userId, ensure category)
312
- const businessesToInsert = results.map(b => ({
313
- ...b,
314
- userId,
315
- category: b.category || "Unknown",
316
- emailStatus: b.emailStatus || null,
317
- }));
318
-
319
- // Use INSERT ON CONFLICT DO NOTHING approach
320
- try {
321
- await db.insert(businesses).values(businessesToInsert).onConflictDoNothing();
322
- } catch (e: unknown) {
323
- console.error(" ❌ Failed to insert businesses (onConflictDoNothing):", e instanceof Error ? e.message : String(e));
324
- // Fallback if onConflictDoNothing is not supported by driver or schema setup
325
- await db.insert(businesses).values(businessesToInsert).catch((err: unknown) => {
326
- console.error(" ❌ Fallback insert also failed:", err instanceof Error ? err.message : String(err));
327
- });
328
- }
329
-
330
- totalFound += results.length;
331
-
332
- // Update progress
333
- await db
334
- .update(scrapingJobs)
335
- .set({
336
- businessesFound: totalFound
337
- })
338
- .where(eq(scrapingJobs.id, jobId));
339
- }
340
 
341
- // Break if we hit safety limit
342
- if (loopCount >= MAX_LOOPS) {
343
- console.log(` ⚠️ Reached safety limit of ${MAX_LOOPS} loops. Finishing.`);
344
- break;
345
- }
346
 
347
- // Yield control briefly
348
- await new Promise(resolve => setTimeout(resolve, 1000));
349
  }
350
 
351
- // Final completion update
352
- await db
353
- .update(scrapingJobs)
354
- .set({
355
- status: "completed",
356
- businessesFound: totalFound,
357
- completedAt: new Date(),
358
- })
359
- .where(eq(scrapingJobs.id, jobId));
360
-
361
- console.log(`✅ Scraping completed: ${totalFound} total businesses found`);
362
- return { success: true, count: totalFound };
363
-
364
- } catch (error: unknown) {
365
- console.error(`❌ Job failed:`, error instanceof Error ? error.message : String(error));
366
- await db
367
- .update(scrapingJobs)
368
- .set({ status: "failed", completedAt: new Date() })
369
- .where(eq(scrapingJobs.id, jobId));
370
- throw new Error(error instanceof Error ? error.message : String(error));
371
  }
372
- },
373
- {
374
- connection: safeConnection as any,
375
- concurrency: 5 // Allow 5 concurrent jobs
376
- }
377
- );
378
 
379
- // Event listeners for scraping worker
380
- scrapingWorker.on("completed", (job) => {
381
- console.log(`✅ Scraping job ${job.id} completed`);
382
- });
 
 
 
 
383
 
384
- scrapingWorker.on("failed", (job, err) => {
385
- console.error(`❌ Scraping job ${job?.id} failed:`, err);
386
- });
 
387
 
 
 
388
 
389
- /**
390
- * Workflow execution queue
391
- */
392
- export const workflowQueue = new Queue("workflow-execution", { connection: safeConnection as any });
393
 
394
- interface WorkflowJobData {
395
- workflowId: string;
396
- userId: string;
397
- businessId: string;
398
- executionId: string;
399
  }
400
 
401
- /**
402
- * Add workflow execution to queue
403
- */
404
- export async function queueWorkflowExecution(data: WorkflowJobData) {
405
- await workflowQueue.add(
406
- "execute-workflow",
407
- data,
408
- {
409
- attempts: 3,
410
- backoff: {
411
- type: "exponential",
412
- delay: 2000,
413
- },
414
- removeOnComplete: { count: 100 },
415
- removeOnFail: { count: 200 },
416
- }
417
- );
418
- }
419
 
420
- /**
421
- * Workflow worker - processes workflow execution jobs
422
- */
423
- export const workflowWorker = new Worker(
424
- "workflow-execution",
425
- async (job: Job<WorkflowJobData>) => {
426
- const { workflowId, userId, businessId, executionId } = job.data;
427
- console.log(`\n🚀 Processing workflow job ${job.id} (ExecID: ${executionId})`);
428
-
429
- // Dynamic import to avoid circular dependencies if any
430
- const { WorkflowExecutor } = await import("./workflow-executor");
431
- const { automationWorkflows, workflowExecutionLogs } = await import("@/db/schema");
432
- const { eq } = await import("drizzle-orm");
433
- const { db } = await import("@/db");
434
 
435
- try {
436
- // 1. Fetch Workflow & Business Data
437
- const workflow = await db.query.automationWorkflows.findFirst({
438
- where: eq(automationWorkflows.id, workflowId)
439
- });
 
 
 
 
 
 
 
 
440
 
441
- if (!workflow) throw new Error("Workflow not found");
 
 
442
 
443
- const business = await db.query.businesses.findFirst({
444
- where: eq(businesses.id, businessId)
445
- });
446
 
447
- if (!business) throw new Error("Business not found");
 
 
448
 
449
- // 2. Instantiate Executor
450
- // Import types dynamically or assume they are available
451
- // specific type imports might be needed if strictly typed
 
 
 
 
452
 
453
- const executor = new WorkflowExecutor(
454
- workflow.nodes as any,
455
- workflow.edges as any,
456
- {
457
- businessId: business.id,
458
- businessData: business as any,
459
- variables: {},
460
- userId,
461
- workflowId: workflow.id,
462
- }
463
- );
 
 
 
 
 
 
464
 
465
- // 3. Execute
466
- const result = await executor.execute();
467
- const finalState = executor.getVariables();
468
-
469
- // 4. Update Status based on result
470
- await db
471
- .update(workflowExecutionLogs)
472
- .set({
473
- status: result.success ? "success" : "failed",
474
- logs: JSON.stringify(result.logs),
475
- state: finalState,
476
- completedAt: new Date(),
477
- })
478
- .where(eq(workflowExecutionLogs.id, executionId));
479
-
480
- if (result.success) {
481
- // Update Workflow lastRunAt and executionCount
482
- await db
483
- .update(automationWorkflows)
484
- .set({
485
- lastRunAt: new Date(),
486
- executionCount: (workflow.executionCount || 0) + 1
487
- })
488
- .where(eq(automationWorkflows.id, workflowId));
489
- } else {
490
- throw new Error("Workflow execution logic returned failure");
491
- }
492
 
493
- return { success: true };
 
 
494
 
495
- } catch (error: unknown) {
496
- const msg = error instanceof Error ? error.message : String(error);
497
- console.error(` Workflow job ${job.id} failed:`, msg);
 
498
 
499
- // Update log to failed
500
- await db
501
- .update(workflowExecutionLogs)
502
- .set({
503
- status: "failed",
504
- error: msg,
505
- completedAt: new Date(),
506
- })
507
- .where(eq(workflowExecutionLogs.id, executionId));
508
 
509
- throw new Error(msg);
 
 
 
510
  }
511
- },
512
- { connection: safeConnection as any, concurrency: 10 }
513
- );
514
-
515
- workflowWorker.on("completed", (job) => {
516
- console.log(`✅ Workflow job ${job.id} completed`);
517
- });
518
-
519
- workflowWorker.on("failed", async (job, err) => {
520
- console.error(`❌ Workflow job ${job?.id} failed:`, err);
521
-
522
- // Example: Check attempts and notify if max reached
523
- if (job && job.attemptsMade >= job.opts.attempts!) {
524
- const { notifications, users } = await import("@/db/schema");
525
- const { db } = await import("@/db");
526
- const { eq } = await import("drizzle-orm");
527
- const { sendWhatsAppMessage } = await import("@/lib/whatsapp/client");
528
 
529
- // 1. Create In-App Notification
530
  await db.insert(notifications).values({
531
  userId: job.data.userId,
532
- title: "Workflow Complete Failure",
533
  message: `Workflow ${job.data.workflowId} failed after ${job.opts.attempts} attempts. Error: ${err.message}`,
534
  level: "error",
535
- category: "workflow"
536
  });
537
 
538
- // 2. Send WhatsApp Alert if User has phone number
539
  try {
540
  const user = await db.query.users.findFirst({
541
- where: eq(users.id, job.data.userId)
542
  });
543
 
544
- if (user && user.phone) {
545
- // Using a text message for internal alerts (or use a template if enforced)
546
- // System alerts usually need a utility template like "alert_notification"
547
- // For MVP: text
548
  await sendWhatsAppMessage({
549
  to: user.phone,
550
- text: `🚨 Critical Alert: Workflow ${job.data.executionId} FAILED.\nError: ${err.message}`
551
  });
552
- console.log(`📱 WhatsApp Alert sent to ${user.phone}`);
553
  }
554
- } catch (waError) {
555
- console.error("Failed to send WhatsApp alert:", waError);
556
  }
557
- }
558
- });
559
 
 
 
560
 
561
- /**
562
- * Queue statistics
563
- */
564
- export async function getQueueStats() {
565
- const [emailStats, scrapingStats, workflowStats] = await Promise.all([
566
- Promise.all([
567
- emailQueue.getWaitingCount(),
568
- emailQueue.getActiveCount(),
569
- emailQueue.getCompletedCount(),
570
- emailQueue.getFailedCount(),
571
- ]).then(([waiting, active, completed, failed]) => ({
572
- waiting,
573
- active,
574
- completed,
575
- failed,
576
- })),
577
- Promise.all([
578
- scrapingQueue.getWaitingCount(),
579
- scrapingQueue.getActiveCount(),
580
- scrapingQueue.getCompletedCount(),
581
- scrapingQueue.getFailedCount(),
582
- ]).then(([waiting, active, completed, failed]) => ({
583
- waiting,
584
- active,
585
- completed,
586
- failed,
587
- })),
588
- Promise.all([
589
- workflowQueue.getWaitingCount(),
590
- workflowQueue.getActiveCount(),
591
- workflowQueue.getCompletedCount(),
592
- workflowQueue.getFailedCount(),
593
- ]).then(([waiting, active, completed, failed]) => ({
594
- waiting,
595
- active,
596
- completed,
597
- failed,
598
- })),
599
- ]);
600
 
601
  return {
602
- email: emailStats,
603
- scraping: scrapingStats,
604
- workflow: workflowStats
605
  };
606
  }
607
 
608
- // Event handlers
609
- emailWorker.on("completed", (job) => {
610
- console.log(`✅ Email job ${job.id} completed`);
611
- });
612
-
613
- emailWorker.on("failed", (job, err) => {
614
- console.error(`❌ Email job ${job?.id} failed:`, err.message);
615
- });
 
 
 
 
 
 
 
 
 
616
 
 
 
 
617
 
 
618
 
619
- /**
620
- * Initialize and return queue workers for use in a dedicated worker process.
621
- * Workers are created on import; this function ensures listeners are attached
622
- * and returns the worker instances for any external orchestration.
623
- */
624
- export async function startWorker() {
625
- try {
626
- // attach idempotent markers to avoid duplicate listeners
627
- if (!(emailWorker as any).__started) {
628
- emailWorker.on("completed", (job) => {
629
- console.log(`✅ Email job ${job.id} completed`);
630
- });
631
- emailWorker.on("failed", (job, err) => {
632
- console.error(`❌ Email job ${job?.id} failed:`, (err as Error).message ?? String(err));
633
- });
634
- (emailWorker as any).__started = true;
635
  }
636
 
637
- if (!(scrapingWorker as any).__started) {
638
- scrapingWorker.on("completed", (job) => {
639
- console.log(`✅ Scraping job ${job.id} completed`);
640
- });
641
- scrapingWorker.on("failed", (job, err) => {
642
- console.error(`❌ Scraping job ${job?.id} failed:`, (err as Error).message ?? String(err));
643
- });
644
- (scrapingWorker as any).__started = true;
645
- }
 
 
 
 
 
 
646
 
647
- if (!(workflowWorker as any).__started) {
648
- workflowWorker.on("completed", (job) => {
649
- console.log(`✅ Workflow job ${job.id} completed`);
650
- });
651
- workflowWorker.on("failed", (job, err) => {
652
- console.error(`❌ Workflow job ${job?.id} failed:`, (err as Error).message ?? String(err));
653
- });
654
- (workflowWorker as any).__started = true;
655
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
657
  return {
658
- emailWorker,
659
- scrapingWorker,
660
- workflowWorker,
661
  };
662
- } catch (err) {
663
- console.error("Failed to start workers:", err);
664
- throw err;
 
 
 
 
 
 
 
665
  }
666
  }
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Job, Queue, Worker } from "bullmq";
3
+ import { and, eq, gte, isNull, lt, or, sql } from "drizzle-orm";
4
+ import type Redis from "ioredis";
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ import { db } from "@/db";
7
+ import {
8
+ automationWorkflows,
9
+ businesses,
10
+ emailLogs,
11
+ emailTemplates,
12
+ notifications,
13
+ users,
14
+ workflowExecutionLogs,
15
+ } from "@/db/schema";
16
+ import { interpolateTemplate, sendColdEmail } from "@/lib/email";
17
+ import { getRedis } from "@/lib/redis";
18
+ import { sendWhatsAppMessage } from "@/lib/whatsapp/client";
19
+ import type { ScraperSourceName } from "@/lib/scrapers/types";
20
 
21
  interface EmailJobData {
22
  userId: string;
 
34
  sources?: ScraperSourceName[];
35
  }
36
 
37
+ interface WorkflowJobData {
38
+ workflowId: string;
39
+ userId: string;
40
+ businessId: string;
41
+ executionId: string;
42
+ }
43
+
44
+ interface QueueStats {
45
+ waiting: number;
46
+ active: number;
47
+ completed: number;
48
+ failed: number;
49
+ }
50
+
51
+ interface QueueWorkers {
52
+ emailWorker: Worker<EmailJobData>;
53
+ scrapingWorker: Worker<ScrapingJobData>;
54
+ workflowWorker: Worker<WorkflowJobData>;
55
+ }
56
+
57
+ let queueConnection: Redis | null = null;
58
+ let emailQueueInstance: Queue<EmailJobData> | null = null;
59
+ let scrapingQueueInstance: Queue<ScrapingJobData> | null = null;
60
+ let workflowQueueInstance: Queue<WorkflowJobData> | null = null;
61
+ let workerBundle: QueueWorkers | null = null;
62
+
63
+ function getQueueConnection(): Redis {
64
+ if (queueConnection) {
65
+ return queueConnection;
66
+ }
67
+
68
+ const connection = getRedis();
69
+
70
+ if (!connection) {
71
+ throw new Error("Redis/Valkey is required for queue operations.");
72
+ }
73
+
74
+ queueConnection = connection;
75
+ return connection;
76
+ }
77
+
78
+ function getEmailQueue() {
79
+ if (!emailQueueInstance) {
80
+ emailQueueInstance = new Queue<EmailJobData>("email-outreach", {
81
+ connection: getQueueConnection() as any,
82
+ });
83
+ }
84
+
85
+ return emailQueueInstance;
86
+ }
87
+
88
+ function getScrapingQueue() {
89
+ if (!scrapingQueueInstance) {
90
+ scrapingQueueInstance = new Queue<ScrapingJobData>("google-maps-scraping", {
91
+ connection: getQueueConnection() as any,
92
+ });
93
+ }
94
+
95
+ return scrapingQueueInstance;
96
+ }
97
+
98
+ function getWorkflowQueue() {
99
+ if (!workflowQueueInstance) {
100
+ workflowQueueInstance = new Queue<WorkflowJobData>("workflow-execution", {
101
+ connection: getQueueConnection() as any,
102
+ });
103
+ }
104
+
105
+ return workflowQueueInstance;
106
+ }
107
+
108
  export async function queueEmail(data: EmailJobData) {
109
+ await getEmailQueue().add("send-email", data, {
110
+ attempts: 3,
111
+ backoff: {
112
+ type: "exponential",
113
+ delay: 2000,
114
+ },
115
+ });
 
 
 
 
116
  }
117
 
 
 
 
118
  export async function queueScraping(params: ScrapingJobData) {
119
+ await getScrapingQueue().add("scrape-google-maps", params, {
120
  attempts: 3,
121
  backoff: { type: "exponential", delay: 2000 },
122
  removeOnComplete: { count: 100 },
 
124
  });
125
  }
126
 
127
+ export async function queueWorkflowExecution(data: WorkflowJobData) {
128
+ await ensureWorkflowExecutionQueued(data);
129
+ }
 
 
 
 
130
 
131
+ async function ensureWorkflowExecutionQueued(data: WorkflowJobData) {
132
+ const queue = getWorkflowQueue();
133
+ const existingJob = await queue.getJob(data.executionId);
 
 
 
 
134
 
135
+ if (existingJob) {
136
+ return existingJob;
137
+ }
138
 
139
+ return queue.add("execute-workflow", data, {
140
+ jobId: data.executionId,
141
+ attempts: 3,
142
+ backoff: {
143
+ type: "exponential",
144
+ delay: 2000,
145
+ },
146
+ removeOnComplete: { count: 100 },
147
+ removeOnFail: { count: 200 },
148
+ });
149
+ }
150
 
151
+ async function processEmailJob(job: Job<EmailJobData>) {
152
+ const { userId, businessId, templateId, accessToken } = job.data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ try {
155
+ const [business] = await db
156
+ .select()
157
+ .from(businesses)
158
+ .where(eq(businesses.id, businessId))
159
+ .limit(1);
160
+
161
+ if (!business) {
162
+ throw new Error("Business not found");
163
+ }
164
+
165
+ const [template] = await db
166
+ .select()
167
+ .from(emailTemplates)
168
+ .where(eq(emailTemplates.id, templateId))
169
+ .limit(1);
170
 
171
+ if (!template) {
172
+ throw new Error("Template not found");
173
+ }
174
 
175
+ const startOfDay = new Date();
176
+ startOfDay.setHours(0, 0, 0, 0);
177
+
178
+ const endOfDay = new Date();
179
+ endOfDay.setHours(23, 59, 59, 999);
180
+
181
+ const [usage] = await db
182
+ .select({ count: sql<number>`count(*)` })
183
+ .from(emailLogs)
184
+ .where(
185
+ and(
186
+ eq(emailLogs.userId, userId),
187
+ eq(emailLogs.status, "sent"),
188
+ gte(emailLogs.sentAt, startOfDay),
189
+ lt(emailLogs.sentAt, endOfDay)
190
+ )
191
+ );
192
+
193
+ if (usage && usage.count >= 50) {
194
+ const now = new Date();
195
+ const tomorrow = new Date(now);
196
+ tomorrow.setDate(tomorrow.getDate() + 1);
197
+ tomorrow.setHours(0, 0, 0, 0);
198
+ const delay = tomorrow.getTime() - now.getTime() + 60000;
199
+
200
+ console.log(
201
+ `Daily email limit reached (${usage.count}/50). Delaying job ${job.id} by ${Math.round(
202
+ delay / 1000 / 60
203
+ )} minutes.`
204
+ );
205
+
206
+ await job.moveToDelayed(Date.now() + delay, job.token);
207
+ return { delayed: true, reason: "Daily limit reached" };
208
+ }
209
+
210
+ const sender = await db.query.users.findFirst({
211
+ where: eq(users.id, userId),
212
+ });
213
+
214
+ if (!sender) {
215
+ throw new Error("Sender not found");
216
+ }
217
+
218
+ const [logEntry] = await db
219
+ .insert(emailLogs)
220
+ .values({
221
  userId,
222
  businessId: business.id,
223
  templateId: template.id,
224
  subject: interpolateTemplate(template.subject, business, sender),
225
+ body: "",
226
  status: "pending",
227
  errorMessage: null,
228
  sentAt: null,
229
+ })
230
+ .returning();
231
+
232
+ const trackingDomain = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
233
+ const pixelUrl = `${trackingDomain}/api/tracking/open?id=${logEntry.id}`;
234
+ const trackingPixel =
235
+ `<img src="${pixelUrl}" alt="" width="1" height="1" style="display:none;" />`;
236
+
237
+ let finalBody = interpolateTemplate(template.body, business, sender);
238
+ finalBody = finalBody.replace(/href=["']([^"']+)["']/g, (_match, url) => {
239
+ const encodedUrl = encodeURIComponent(url);
240
+ const trackingUrl = `${trackingDomain}/api/tracking/click?id=${logEntry.id}&url=${encodedUrl}`;
241
+ return `href="${trackingUrl}"`;
242
+ });
243
+ finalBody += trackingPixel;
244
 
245
+ await db.update(emailLogs).set({ body: finalBody }).where(eq(emailLogs.id, logEntry.id));
246
 
247
+ const emailResult = await sendColdEmail(business, template, accessToken, sender);
248
+ const success = emailResult.success;
249
+ const error = emailResult.error;
250
 
251
+ await db
252
+ .update(emailLogs)
253
+ .set({
254
+ status: success ? "sent" : "failed",
255
+ errorMessage: error,
256
+ sentAt: success ? new Date() : null,
257
+ })
258
+ .where(eq(emailLogs.id, logEntry.id));
259
+
260
+ await db
261
+ .update(businesses)
262
+ .set({
263
+ emailSent: true,
264
+ emailSentAt: new Date(),
265
+ emailStatus: success ? "sent" : "failed",
266
+ updatedAt: new Date(),
267
+ })
268
+ .where(eq(businesses.id, business.id));
269
+
270
+ if (!success) {
271
+ throw new Error(error || "Failed to send email");
272
+ }
273
 
274
+ return { success: true, businessId };
275
+ } catch (error: unknown) {
276
+ const errorMessage = error instanceof Error ? error.message : String(error);
277
+
278
+ await db.insert(emailLogs).values({
279
+ userId,
280
+ businessId,
281
+ templateId,
282
+ subject: "Cold Outreach",
283
+ body: "Failed to send",
284
+ status: "failed",
285
+ errorMessage,
286
+ });
287
 
288
+ throw new Error(errorMessage);
289
+ }
290
+ }
291
 
292
+ async function processScrapingJob(job: Job<ScrapingJobData>) {
293
+ const { userId, jobId, keywords, location, sources = ["google-maps"] } = job.data;
294
+ console.log(`Starting scraping job ${job.id} for record ${jobId}`);
295
 
296
+ const { scrapingJobs } = await import("@/db/schema");
297
+ const { eq } = await import("drizzle-orm");
298
+ const { scrapeMultiSource } = await import("./scrapers");
 
 
 
 
299
 
300
+ try {
301
+ let [jobRecord] = await db
302
+ .select()
303
+ .from(scrapingJobs)
304
+ .where(eq(scrapingJobs.id, jobId))
305
+ .limit(1);
306
+
307
+ if (!jobRecord) {
308
+ throw new Error(`Job ${jobId} not found`);
309
+ }
310
 
311
+ await db.update(scrapingJobs).set({ status: "running" }).where(eq(scrapingJobs.id, jobId));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ let totalFound = jobRecord.businessesFound || 0;
314
+ let loopCount = 0;
315
+ const maxLoops = 50;
 
 
 
 
 
 
 
 
 
 
316
 
317
+ while (true) {
318
+ loopCount += 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
+ [jobRecord] = await db
 
 
321
  .select()
322
  .from(scrapingJobs)
323
  .where(eq(scrapingJobs.id, jobId))
324
  .limit(1);
325
 
326
+ if (!jobRecord) {
327
+ break;
328
+ }
 
 
 
 
 
 
 
 
 
 
 
329
 
330
+ if (jobRecord.status === "paused") {
331
+ await new Promise((resolve) => setTimeout(resolve, 5000));
332
+ continue;
333
+ }
334
 
335
+ if (jobRecord.status === "failed" || jobRecord.status === "completed") {
336
+ break;
337
+ }
 
 
 
338
 
339
+ const jitter = Math.floor(Math.random() * 5000);
340
+ await new Promise((resolve) => setTimeout(resolve, 2000 + jitter));
341
 
342
+ const results = await scrapeMultiSource(
343
+ {
344
+ keywords,
345
+ location,
346
+ limit: 20,
347
+ sources,
348
+ },
349
+ userId
350
+ );
351
 
352
+ if (results.length > 0) {
353
+ const businessesToInsert = results.map((business) => ({
354
+ ...business,
355
+ userId,
356
+ category: business.category || "Unknown",
357
+ emailStatus: business.emailStatus || null,
358
+ }));
359
+
360
+ try {
361
+ await db.insert(businesses).values(businessesToInsert).onConflictDoNothing();
362
+ } catch (error: unknown) {
363
+ console.error(
364
+ "Failed to insert scraped businesses:",
365
+ error instanceof Error ? error.message : String(error)
366
+ );
367
+
368
+ await db.insert(businesses).values(businessesToInsert).catch((fallbackError: unknown) => {
369
+ console.error(
370
+ "Fallback insert failed:",
371
+ fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
372
+ );
373
+ });
374
  }
375
 
376
+ totalFound += results.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
+ await db
379
+ .update(scrapingJobs)
380
+ .set({ businessesFound: totalFound })
381
+ .where(eq(scrapingJobs.id, jobId));
382
+ }
383
 
384
+ if (loopCount >= maxLoops) {
385
+ break;
386
  }
387
 
388
+ await new Promise((resolve) => setTimeout(resolve, 1000));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  }
 
 
 
 
 
 
390
 
391
+ await db
392
+ .update(scrapingJobs)
393
+ .set({
394
+ status: "completed",
395
+ businessesFound: totalFound,
396
+ completedAt: new Date(),
397
+ })
398
+ .where(eq(scrapingJobs.id, jobId));
399
 
400
+ return { success: true, count: totalFound };
401
+ } catch (error: unknown) {
402
+ const message = error instanceof Error ? error.message : String(error);
403
+ console.error(`Scraping job ${job.id} failed:`, message);
404
 
405
+ const { scrapingJobs } = await import("@/db/schema");
406
+ const { eq } = await import("drizzle-orm");
407
 
408
+ await db
409
+ .update(scrapingJobs)
410
+ .set({ status: "failed", completedAt: new Date() })
411
+ .where(eq(scrapingJobs.id, jobId));
412
 
413
+ throw new Error(message);
414
+ }
 
 
 
415
  }
416
 
417
+ async function processWorkflowJob(job: Job<WorkflowJobData>) {
418
+ const { workflowId, userId, businessId, executionId } = job.data;
419
+ console.log(`Processing workflow job ${job.id} (execution ${executionId})`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
+ const { WorkflowExecutor } = await import("./workflow-executor");
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
+ try {
424
+ await db
425
+ .update(workflowExecutionLogs)
426
+ .set({
427
+ status: "running",
428
+ startedAt: new Date(),
429
+ updatedAt: new Date(),
430
+ })
431
+ .where(eq(workflowExecutionLogs.id, executionId));
432
+
433
+ const workflow = await db.query.automationWorkflows.findFirst({
434
+ where: eq(automationWorkflows.id, workflowId),
435
+ });
436
 
437
+ if (!workflow) {
438
+ throw new Error("Workflow not found");
439
+ }
440
 
441
+ const business = await db.query.businesses.findFirst({
442
+ where: eq(businesses.id, businessId),
443
+ });
444
 
445
+ if (!business) {
446
+ throw new Error("Business not found");
447
+ }
448
 
449
+ const executor = new WorkflowExecutor(workflow.nodes as any, workflow.edges as any, {
450
+ businessId: business.id,
451
+ businessData: business as any,
452
+ variables: {},
453
+ userId,
454
+ workflowId: workflow.id,
455
+ });
456
 
457
+ const result = await executor.execute();
458
+ const finalState = executor.getVariables();
459
+
460
+ await db
461
+ .update(workflowExecutionLogs)
462
+ .set({
463
+ status: result.success ? "success" : "failed",
464
+ logs: JSON.stringify(result.logs),
465
+ state: finalState,
466
+ completedAt: new Date(),
467
+ updatedAt: new Date(),
468
+ })
469
+ .where(eq(workflowExecutionLogs.id, executionId));
470
+
471
+ if (!result.success) {
472
+ throw new Error("Workflow execution logic returned failure");
473
+ }
474
 
475
+ await db
476
+ .update(automationWorkflows)
477
+ .set({
478
+ lastRunAt: new Date(),
479
+ executionCount: (workflow.executionCount || 0) + 1,
480
+ })
481
+ .where(eq(automationWorkflows.id, workflowId));
482
+
483
+ return { success: true };
484
+ } catch (error: unknown) {
485
+ const message = error instanceof Error ? error.message : String(error);
486
+ console.error(`Workflow job ${job.id} failed:`, message);
487
+
488
+ await db
489
+ .update(workflowExecutionLogs)
490
+ .set({
491
+ status: "failed",
492
+ error: message,
493
+ completedAt: new Date(),
494
+ updatedAt: new Date(),
495
+ })
496
+ .where(eq(workflowExecutionLogs.id, executionId));
 
 
 
 
 
497
 
498
+ throw new Error(message);
499
+ }
500
+ }
501
 
502
+ function attachCommonWorkerListeners<T>(worker: Worker<T>, label: string) {
503
+ worker.on("completed", (job) => {
504
+ console.log(`${label} job ${job.id} completed`);
505
+ });
506
 
507
+ worker.on("failed", (job, err) => {
508
+ console.error(`${label} job ${job?.id} failed:`, err.message);
509
+ });
510
+ }
 
 
 
 
 
511
 
512
+ async function attachWorkflowFailureListener(worker: Worker<WorkflowJobData>) {
513
+ worker.on("failed", async (job, err) => {
514
+ if (!job || job.attemptsMade < (job.opts.attempts ?? 0)) {
515
+ return;
516
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
 
518
  await db.insert(notifications).values({
519
  userId: job.data.userId,
520
+ title: "Workflow Failure",
521
  message: `Workflow ${job.data.workflowId} failed after ${job.opts.attempts} attempts. Error: ${err.message}`,
522
  level: "error",
523
+ category: "workflow",
524
  });
525
 
 
526
  try {
527
  const user = await db.query.users.findFirst({
528
+ where: eq(users.id, job.data.userId),
529
  });
530
 
531
+ if (user?.phone) {
 
 
 
532
  await sendWhatsAppMessage({
533
  to: user.phone,
534
+ text: `Critical alert: workflow ${job.data.executionId} failed. Error: ${err.message}`,
535
  });
 
536
  }
537
+ } catch (whatsAppError) {
538
+ console.error("Failed to send workflow WhatsApp alert:", whatsAppError);
539
  }
540
+ });
541
+ }
542
 
543
+ function createWorkers(): QueueWorkers {
544
+ const connection = getQueueConnection();
545
 
546
+ const emailWorker = new Worker<EmailJobData>("email-outreach", processEmailJob, {
547
+ connection: connection as any,
548
+ });
549
+
550
+ const scrapingWorker = new Worker<ScrapingJobData>("google-maps-scraping", processScrapingJob, {
551
+ connection: connection as any,
552
+ concurrency: 5,
553
+ });
554
+
555
+ const workflowWorker = new Worker<WorkflowJobData>(
556
+ "workflow-execution",
557
+ processWorkflowJob,
558
+ {
559
+ connection: connection as any,
560
+ concurrency: 10,
561
+ }
562
+ );
563
+
564
+ attachCommonWorkerListeners(emailWorker, "Email");
565
+ attachCommonWorkerListeners(scrapingWorker, "Scraping");
566
+ attachCommonWorkerListeners(workflowWorker, "Workflow");
567
+ void attachWorkflowFailureListener(workflowWorker);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
 
569
  return {
570
+ emailWorker,
571
+ scrapingWorker,
572
+ workflowWorker,
573
  };
574
  }
575
 
576
+ async function recoverIncompleteWorkflowExecutions() {
577
+ const recoverableExecutions = await db.query.workflowExecutionLogs.findMany({
578
+ where: and(
579
+ or(
580
+ eq(workflowExecutionLogs.status, "pending"),
581
+ eq(workflowExecutionLogs.status, "running")
582
+ ),
583
+ isNull(workflowExecutionLogs.completedAt)
584
+ ),
585
+ columns: {
586
+ id: true,
587
+ workflowId: true,
588
+ userId: true,
589
+ businessId: true,
590
+ status: true,
591
+ },
592
+ });
593
 
594
+ if (recoverableExecutions.length === 0) {
595
+ return 0;
596
+ }
597
 
598
+ let recoveredCount = 0;
599
 
600
+ for (const execution of recoverableExecutions) {
601
+ if (!execution.businessId) {
602
+ continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  }
604
 
605
+ await db
606
+ .update(workflowExecutionLogs)
607
+ .set({
608
+ status: "pending",
609
+ error: null,
610
+ updatedAt: new Date(),
611
+ })
612
+ .where(eq(workflowExecutionLogs.id, execution.id));
613
+
614
+ await ensureWorkflowExecutionQueued({
615
+ workflowId: execution.workflowId,
616
+ userId: execution.userId,
617
+ businessId: execution.businessId,
618
+ executionId: execution.id,
619
+ });
620
 
621
+ recoveredCount += 1;
622
+ }
623
+
624
+ console.log(`Recovered ${recoveredCount} incomplete workflow executions from Postgres.`);
625
+ return recoveredCount;
626
+ }
627
+
628
+ async function getQueueSnapshot(queue: Queue<any, any, string>): Promise<QueueStats> {
629
+ const [waiting, active, completed, failed] = await Promise.all([
630
+ queue.getWaitingCount(),
631
+ queue.getActiveCount(),
632
+ queue.getCompletedCount(),
633
+ queue.getFailedCount(),
634
+ ]);
635
+
636
+ return {
637
+ waiting,
638
+ active,
639
+ completed,
640
+ failed,
641
+ };
642
+ }
643
+
644
+ export async function getQueueStats() {
645
+ try {
646
+ const [emailStats, scrapingStats, workflowStats] = await Promise.all([
647
+ getQueueSnapshot(getEmailQueue()),
648
+ getQueueSnapshot(getScrapingQueue()),
649
+ getQueueSnapshot(getWorkflowQueue()),
650
+ ]);
651
 
652
  return {
653
+ email: emailStats,
654
+ scraping: scrapingStats,
655
+ workflow: workflowStats,
656
  };
657
+ } catch (error) {
658
+ if (error instanceof Error && error.message.includes("Redis/Valkey is required")) {
659
+ return {
660
+ email: { waiting: 0, active: 0, completed: 0, failed: 0 },
661
+ scraping: { waiting: 0, active: 0, completed: 0, failed: 0 },
662
+ workflow: { waiting: 0, active: 0, completed: 0, failed: 0 },
663
+ };
664
+ }
665
+
666
+ throw error;
667
  }
668
  }
669
 
670
+ export async function startWorker() {
671
+ if (process.env.START_QUEUE_WORKERS !== "true") {
672
+ throw new Error("Queue workers are disabled in this process.");
673
+ }
674
+
675
+ if (!workerBundle) {
676
+ workerBundle = createWorkers();
677
+ await recoverIncompleteWorkflowExecutions();
678
+ }
679
+
680
+ return workerBundle;
681
+ }
lib/rate-limit.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { redis } from "@/lib/redis";
2
  import { NextResponse } from "next/server";
3
  import { NextRequest } from "next/server";
4
 
@@ -52,6 +52,8 @@ export class RateLimiter {
52
  * @returns { success: boolean, remaining: number, reset: number }
53
  */
54
  static async check(key: string, config: RateLimitConfig) {
 
 
55
  if (!redis) {
56
  console.warn("Redis not available, skipping rate limit check");
57
  return { success: true, remaining: 1, reset: 0 };
@@ -95,6 +97,8 @@ export class RateLimiter {
95
  * Cleanup rate limit key (clear from Redis)
96
  */
97
  static async cleanup(key: string) {
 
 
98
  if (redis) {
99
  await redis.del(key);
100
  }
@@ -105,6 +109,8 @@ export class RateLimiter {
105
  * Legacy rate limit function for backward compatibility
106
  */
107
  export async function rateLimit(request: Request, context: string = "general") {
 
 
108
  if (!redis) return null; // Skip if no Redis
109
 
110
  const ip = request.headers.get("x-forwarded-for") || "unknown";
@@ -147,6 +153,8 @@ export async function checkRateLimit(
147
  reset: number;
148
  response?: NextResponse;
149
  }> {
 
 
150
  if (!redis) {
151
  return { limited: false, remaining: 999, reset: 0 };
152
  }
@@ -193,6 +201,7 @@ export async function getRemainingEmails(userId: string): Promise<number> {
193
  // Limit: 50 emails per day
194
  const key = `email_limit:${userId}`;
195
  const config = RATE_LIMIT_CONFIG.email;
 
196
 
197
  if (!redis) return 50;
198
 
 
1
+ import { getRedis } from "@/lib/redis";
2
  import { NextResponse } from "next/server";
3
  import { NextRequest } from "next/server";
4
 
 
52
  * @returns { success: boolean, remaining: number, reset: number }
53
  */
54
  static async check(key: string, config: RateLimitConfig) {
55
+ const redis = getRedis();
56
+
57
  if (!redis) {
58
  console.warn("Redis not available, skipping rate limit check");
59
  return { success: true, remaining: 1, reset: 0 };
 
97
  * Cleanup rate limit key (clear from Redis)
98
  */
99
  static async cleanup(key: string) {
100
+ const redis = getRedis();
101
+
102
  if (redis) {
103
  await redis.del(key);
104
  }
 
109
  * Legacy rate limit function for backward compatibility
110
  */
111
  export async function rateLimit(request: Request, context: string = "general") {
112
+ const redis = getRedis();
113
+
114
  if (!redis) return null; // Skip if no Redis
115
 
116
  const ip = request.headers.get("x-forwarded-for") || "unknown";
 
153
  reset: number;
154
  response?: NextResponse;
155
  }> {
156
+ const redis = getRedis();
157
+
158
  if (!redis) {
159
  return { limited: false, remaining: 999, reset: 0 };
160
  }
 
201
  // Limit: 50 emails per day
202
  const key = `email_limit:${userId}`;
203
  const config = RATE_LIMIT_CONFIG.email;
204
+ const redis = getRedis();
205
 
206
  if (!redis) return 50;
207
 
lib/redis.ts CHANGED
@@ -1,57 +1,109 @@
1
- import Redis from "ioredis";
2
 
3
- let redis: Redis | null = null;
4
- let connectionAttempted = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export function getRedis(): Redis | null {
7
- if (connectionAttempted) {
8
- return redis;
9
  }
10
 
 
 
11
  try {
12
- connectionAttempted = true;
13
 
14
- // Don't attempt connection if no Redis URL is provided
15
- if (!process.env.REDIS_URL && !process.env.REDIS_HOST) {
16
- console.warn("Redis not configured, continuing without Redis");
17
  return null;
18
  }
19
 
20
- redis = new Redis({
21
- host: process.env.REDIS_HOST || "localhost",
22
- port: parseInt(process.env.REDIS_PORT || "6379"),
23
- password: process.env.REDIS_PASSWORD,
24
- // BullMQ requires this to be null
25
- maxRetriesPerRequest: null,
26
- retryStrategy: (times) => {
27
- if (times > 3) {
28
- console.warn("Redis connection failed after retries, continuing without Redis");
29
- return null; // Stop retrying
30
- }
31
- return Math.min(times * 50, 200);
32
- },
33
- lazyConnect: true,
34
- enableOfflineQueue: false,
35
- });
36
 
37
- // Attempt to connect (non-blocking)
38
- redis.connect().catch((err) => {
39
- console.warn("Redis connection failed:", err.message);
40
- redis = null;
41
- });
42
 
43
- redis.on("error", (err) => {
44
- console.warn("Redis error:", err.message);
45
- });
 
 
 
 
46
 
47
- return redis;
 
 
48
  } catch (error) {
49
- console.warn("Failed to initialize Redis:", error);
50
- redis = null;
 
 
 
 
 
 
 
 
51
  return null;
52
  }
53
  }
54
 
55
- // For backward compatibility
56
- redis = getRedis();
57
- export { redis };
 
 
1
+ import Redis, { type RedisOptions } from "ioredis";
2
 
3
+ let redisClient: Redis | null = null;
4
+ let initializationAttempted = false;
5
+
6
+ function getBaseOptions(): RedisOptions {
7
+ return {
8
+ maxRetriesPerRequest: null,
9
+ retryStrategy: (times) => {
10
+ if (times > 3) {
11
+ console.warn("Redis/Valkey connection failed after retries, continuing without queue cache");
12
+ return null;
13
+ }
14
+
15
+ return Math.min(times * 100, 1000);
16
+ },
17
+ lazyConnect: true,
18
+ enableOfflineQueue: false,
19
+ };
20
+ }
21
+
22
+ function createClient(): Redis | null {
23
+ const redisUrl = process.env.REDIS_URL?.trim();
24
+
25
+ if (redisUrl) {
26
+ return new Redis(redisUrl, getBaseOptions());
27
+ }
28
+
29
+ if (process.env.REDIS_HOST || process.env.REDIS_PORT || process.env.REDIS_PASSWORD) {
30
+ return new Redis({
31
+ ...getBaseOptions(),
32
+ host: process.env.REDIS_HOST || "127.0.0.1",
33
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
34
+ password: process.env.REDIS_PASSWORD,
35
+ });
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function attachListeners(client: Redis) {
42
+ client.on("error", (err) => {
43
+ console.warn("Redis/Valkey error:", err.message);
44
+ });
45
+
46
+ client.on("end", () => {
47
+ console.warn("Redis/Valkey connection closed");
48
+ });
49
+ }
50
 
51
  export function getRedis(): Redis | null {
52
+ if (initializationAttempted) {
53
+ return redisClient;
54
  }
55
 
56
+ initializationAttempted = true;
57
+
58
  try {
59
+ const client = createClient();
60
 
61
+ if (!client) {
62
+ console.warn("Redis/Valkey not configured, continuing without queue cache");
63
+ redisClient = null;
64
  return null;
65
  }
66
 
67
+ attachListeners(client);
68
+ redisClient = client;
69
+ return redisClient;
70
+ } catch (error) {
71
+ console.warn("Failed to initialize Redis/Valkey:", error);
72
+ redisClient = null;
73
+ return null;
74
+ }
75
+ }
 
 
 
 
 
 
 
76
 
77
+ export async function connectRedis(): Promise<Redis | null> {
78
+ const client = getRedis();
 
 
 
79
 
80
+ if (!client) {
81
+ return null;
82
+ }
83
+
84
+ if (client.status === "ready" || client.status === "connecting" || client.status === "connect") {
85
+ return client;
86
+ }
87
 
88
+ try {
89
+ await client.connect();
90
+ return client;
91
  } catch (error) {
92
+ console.warn(
93
+ "Redis/Valkey connection failed:",
94
+ error instanceof Error ? error.message : String(error)
95
+ );
96
+
97
+ if (redisClient === client) {
98
+ redisClient = null;
99
+ initializationAttempted = false;
100
+ }
101
+
102
  return null;
103
  }
104
  }
105
 
106
+ export function resetRedisClient() {
107
+ redisClient = null;
108
+ initializationAttempted = false;
109
+ }
lib/social/event-dedupe.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getRedis } from "@/lib/redis";
2
+
3
+ const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 7;
4
+
5
+ export function buildSocialEventKey(
6
+ provider: string,
7
+ accountId: string,
8
+ eventType: string,
9
+ eventId: string
10
+ ) {
11
+ return `social:event:${provider}:${accountId}:${eventType}:${eventId}`;
12
+ }
13
+
14
+ export async function claimSocialEvent(
15
+ eventKey: string,
16
+ ttlSeconds = DEFAULT_TTL_SECONDS
17
+ ): Promise<boolean> {
18
+ const redis = getRedis();
19
+
20
+ if (!redis) {
21
+ return true;
22
+ }
23
+
24
+ try {
25
+ const result = await redis.set(eventKey, "1", "EX", ttlSeconds, "NX");
26
+ return result === "OK";
27
+ } catch (error) {
28
+ console.error("Failed to claim social event:", error);
29
+ return true;
30
+ }
31
+ }
32
+
33
+ export async function releaseSocialEvent(eventKey: string): Promise<void> {
34
+ const redis = getRedis();
35
+
36
+ if (!redis) {
37
+ return;
38
+ }
39
+
40
+ try {
41
+ await redis.del(eventKey);
42
+ } catch (error) {
43
+ console.error("Failed to release social event:", error);
44
+ }
45
+ }
lib/validations.ts CHANGED
@@ -71,8 +71,15 @@ export const controlScrapingSchema = z.object({
71
 
72
  export const createSocialAutomationSchema = z.object({
73
  name: z.string().min(1, "Automation name is required").max(100),
74
- connectedAccountId: z.string().min(1, "Connected account is required"),
75
- triggerType: z.enum(["comment_keyword", "dm_keyword", "story_mention", "any_comment"]),
 
 
 
 
 
 
 
76
  keywords: z.array(z.string()).optional(),
77
  actionType: z.enum(["reply_comment", "send_dm", "whatsapp_reply"]),
78
  responseTemplate: z.string().min(1, "Response template is required").max(1000),
 
71
 
72
  export const createSocialAutomationSchema = z.object({
73
  name: z.string().min(1, "Automation name is required").max(100),
74
+ connectedAccountId: z.string().min(1, "Connected account is required").optional(),
75
+ triggerType: z.enum([
76
+ "comment_keyword",
77
+ "dm_keyword",
78
+ "story_mention",
79
+ "any_comment",
80
+ "whatsapp_keyword",
81
+ "whatsapp_command",
82
+ ]),
83
  keywords: z.array(z.string()).optional(),
84
  actionType: z.enum(["reply_comment", "send_dm", "whatsapp_reply"]),
85
  responseTemplate: z.string().min(1, "Response template is required").max(1000),
lib/whatsapp/client.ts CHANGED
@@ -7,6 +7,8 @@ interface SendMessageParams {
7
  templateLanguage?: string;
8
  templateComponents?: unknown[];
9
  text?: string;
 
 
10
  }
11
 
12
  const PHONE_ID = process.env.WHATSAPP_PHONE_ID;
@@ -16,8 +18,8 @@ const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN;
16
  * Send a WhatsApp Message via Meta Cloud API
17
  */
18
  export async function sendWhatsAppMessage(payload: SendMessageParams): Promise<{ success: boolean; id?: string; error?: string }> {
19
- const phoneId = PHONE_ID;
20
- const token = ACCESS_TOKEN;
21
 
22
  if (!phoneId || !token) {
23
  return { success: false, error: "Missing WhatsApp Credentials" };
 
7
  templateLanguage?: string;
8
  templateComponents?: unknown[];
9
  text?: string;
10
+ phoneId?: string;
11
+ accessToken?: string;
12
  }
13
 
14
  const PHONE_ID = process.env.WHATSAPP_PHONE_ID;
 
18
  * Send a WhatsApp Message via Meta Cloud API
19
  */
20
  export async function sendWhatsAppMessage(payload: SendMessageParams): Promise<{ success: boolean; id?: string; error?: string }> {
21
+ const phoneId = payload.phoneId || PHONE_ID;
22
+ const token = payload.accessToken || ACCESS_TOKEN;
23
 
24
  if (!phoneId || !token) {
25
  return { success: false, error: "Missing WhatsApp Credentials" };
lib/whatsapp/webhook.ts CHANGED
@@ -1,6 +1,7 @@
 
 
1
  import { db } from "@/db";
2
- import { socialAutomations, whatsappMessages } from "@/db/schema";
3
- import { eq, and } from "drizzle-orm";
4
  import { sendWhatsAppMessage } from "./client";
5
 
6
  interface WhatsAppChange {
@@ -41,33 +42,26 @@ interface WhatsAppStatus {
41
  status: string;
42
  timestamp: string;
43
  recipient_id: string;
44
- conversation?: {
45
- id: string;
46
- expiration_timestamp?: string;
47
- origin?: {
48
- type: string;
49
- };
50
- };
51
- pricing?: {
52
- billable: boolean;
53
- pricing_model: string;
54
- category: string;
55
- };
56
  }
57
 
58
  export async function handleWhatsAppEvent(body: WhatsAppWebhookBody) {
59
- if (body.object === "whatsapp_business_account") {
60
- for (const entry of body.entry) {
61
- for (const change of entry.changes) {
62
- if (change.value.messages) {
63
- for (const message of change.value.messages) {
64
- await processMessage(message);
65
- }
 
 
 
 
66
  }
67
- if (change.value.statuses) {
68
- for (const status of change.value.statuses) {
69
- await processStatusUpdate(status);
70
- }
 
71
  }
72
  }
73
  }
@@ -75,87 +69,100 @@ export async function handleWhatsAppEvent(body: WhatsAppWebhookBody) {
75
  }
76
 
77
  async function processStatusUpdate(status: WhatsAppStatus) {
78
- // status: { id: 'wamid.HBg...', status: 'sent'|'delivered'|'read', timestamp: '...', recipient_id: '...' }
79
- const { id, status: newStatus } = status;
80
-
81
  try {
82
- await db.update(whatsappMessages)
83
- .set({ status: newStatus, updatedAt: new Date() })
84
- .where(eq(whatsappMessages.wamid, id));
85
-
86
- // console.log(`🔄 Message ${id} status: ${newStatus}`);
87
  } catch (error) {
88
- console.error(`Failed to update status for ${id}:`, error);
89
  }
90
  }
91
 
92
- async function processMessage(message: WhatsAppMessage) {
93
- const senderPhone = message.from; // e.g., "15551234567"
94
  const messageId = message.id;
95
- const timestamp = new Date(parseInt(message.timestamp) * 1000);
96
-
97
- let text = "";
98
- const type = message.type; // 'text', 'button', 'interactive'
99
 
100
- if (type === "text" && message.text) {
101
- text = message.text.body;
102
- } else {
103
- // For now, capture type as text but body might be empty or description
104
- text = `[${type} message]`;
105
- }
106
-
107
- console.log(`📩 WhatsApp Message from ${senderPhone}: ${text}`);
108
 
109
- // 1. Log Inbound Message
110
  try {
111
- // Check if exists (dedup logic if needed, but wamid is unique)
112
- await db.insert(whatsappMessages).values({
113
- wamid: messageId,
114
- phoneNumber: senderPhone,
115
- direction: "inbound",
116
- type: type,
117
- status: "received",
118
- body: text,
119
- createdAt: timestamp,
120
- updatedAt: timestamp
121
- }).onConflictDoNothing({ target: whatsappMessages.wamid });
 
 
122
  } catch (error) {
123
- console.error("Failed to log inbound message:", error);
 
 
 
 
124
  }
125
 
126
- if (type !== "text") return; // Only automate text for now
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- // 2. Find Automation Rules (Auto-Reply & Commands)
129
  const rules = await db.query.socialAutomations.findMany({
130
  where: and(
 
131
  eq(socialAutomations.isActive, true)
132
- )
133
  });
134
 
135
  for (const rule of rules) {
136
  let matched = false;
137
 
138
  if (rule.triggerType === "whatsapp_keyword") {
139
- if (rule.keywords && rule.keywords.some(k => text.toLowerCase().includes(k.toLowerCase()))) {
140
- matched = true;
141
- }
142
  } else if (rule.triggerType === "whatsapp_command") {
143
- if (rule.keywords && rule.keywords.some(k => text.toLowerCase().trim() === k.toLowerCase().trim() || text.toLowerCase().startsWith(k.toLowerCase() + " "))) {
144
- matched = true;
145
- }
 
 
 
 
 
146
  }
147
 
148
- if (matched) {
149
- console.log(`✅ Matched WhatsApp Rule: ${rule.name} (${rule.triggerType})`);
 
150
 
151
- // Send Reply
152
- await sendWhatsAppMessage({
153
- to: senderPhone,
154
- text: rule.responseTemplate || ""
155
- });
 
156
 
157
- // Stop after first match? Maybe for commands, yes.
158
- if (rule.triggerType === "whatsapp_command") break;
159
  }
160
  }
161
  }
 
1
+ import { and, eq } from "drizzle-orm";
2
+
3
  import { db } from "@/db";
4
+ import { socialAutomations, users, whatsappMessages } from "@/db/schema";
 
5
  import { sendWhatsAppMessage } from "./client";
6
 
7
  interface WhatsAppChange {
 
42
  status: string;
43
  timestamp: string;
44
  recipient_id: string;
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  export async function handleWhatsAppEvent(body: WhatsAppWebhookBody) {
48
+ if (body.object !== "whatsapp_business_account") {
49
+ return;
50
+ }
51
+
52
+ for (const entry of body.entry) {
53
+ for (const change of entry.changes) {
54
+ const phoneNumberId = change.value.metadata.phone_number_id;
55
+
56
+ if (change.value.messages) {
57
+ for (const message of change.value.messages) {
58
+ await processMessage(message, phoneNumberId);
59
  }
60
+ }
61
+
62
+ if (change.value.statuses) {
63
+ for (const status of change.value.statuses) {
64
+ await processStatusUpdate(status);
65
  }
66
  }
67
  }
 
69
  }
70
 
71
  async function processStatusUpdate(status: WhatsAppStatus) {
 
 
 
72
  try {
73
+ await db
74
+ .update(whatsappMessages)
75
+ .set({ status: status.status, updatedAt: new Date() })
76
+ .where(eq(whatsappMessages.wamid, status.id));
 
77
  } catch (error) {
78
+ console.error(`Failed to update status for ${status.id}:`, error);
79
  }
80
  }
81
 
82
+ async function processMessage(message: WhatsAppMessage, phoneNumberId: string) {
83
+ const senderPhone = message.from;
84
  const messageId = message.id;
85
+ const timestamp = new Date(parseInt(message.timestamp, 10) * 1000);
 
 
 
86
 
87
+ const text =
88
+ message.type === "text" && message.text
89
+ ? message.text.body
90
+ : `[${message.type} message]`;
 
 
 
 
91
 
 
92
  try {
93
+ await db
94
+ .insert(whatsappMessages)
95
+ .values({
96
+ wamid: messageId,
97
+ phoneNumber: senderPhone,
98
+ direction: "inbound",
99
+ type: message.type,
100
+ status: "received",
101
+ body: text,
102
+ createdAt: timestamp,
103
+ updatedAt: timestamp,
104
+ })
105
+ .onConflictDoNothing({ target: whatsappMessages.wamid });
106
  } catch (error) {
107
+ console.error("Failed to log inbound WhatsApp message:", error);
108
+ }
109
+
110
+ if (message.type !== "text") {
111
+ return;
112
  }
113
 
114
+ const user = await db.query.users.findFirst({
115
+ where: eq(users.whatsappBusinessPhone, phoneNumberId),
116
+ columns: {
117
+ id: true,
118
+ whatsappBusinessPhone: true,
119
+ whatsappAccessToken: true,
120
+ },
121
+ });
122
+
123
+ if (!user) {
124
+ console.warn(`No WhatsApp-configured user found for phone number id ${phoneNumberId}`);
125
+ return;
126
+ }
127
 
 
128
  const rules = await db.query.socialAutomations.findMany({
129
  where: and(
130
+ eq(socialAutomations.userId, user.id),
131
  eq(socialAutomations.isActive, true)
132
+ ),
133
  });
134
 
135
  for (const rule of rules) {
136
  let matched = false;
137
 
138
  if (rule.triggerType === "whatsapp_keyword") {
139
+ matched = !!rule.keywords?.some((keyword) =>
140
+ text.toLowerCase().includes(keyword.toLowerCase())
141
+ );
142
  } else if (rule.triggerType === "whatsapp_command") {
143
+ matched = !!rule.keywords?.some((keyword) => {
144
+ const normalizedKeyword = keyword.toLowerCase().trim();
145
+ const normalizedText = text.toLowerCase().trim();
146
+ return (
147
+ normalizedText === normalizedKeyword ||
148
+ normalizedText.startsWith(`${normalizedKeyword} `)
149
+ );
150
+ });
151
  }
152
 
153
+ if (!matched) {
154
+ continue;
155
+ }
156
 
157
+ await sendWhatsAppMessage({
158
+ to: senderPhone,
159
+ text: rule.responseTemplate || "",
160
+ phoneId: user.whatsappBusinessPhone || undefined,
161
+ accessToken: user.whatsappAccessToken || undefined,
162
+ });
163
 
164
+ if (rule.triggerType === "whatsapp_command") {
165
+ break;
166
  }
167
  }
168
  }
lib/workers/index.ts CHANGED
@@ -1,24 +1,42 @@
1
  /**
2
- * Server configuration to start background workers
3
- * This should be called when the server starts
4
  */
5
 
6
- import { startSocialAutomationWorker } from "@/lib/workers/social-automation";
 
 
 
 
 
7
 
8
  export async function startBackgroundWorkers() {
9
- console.log("🚀 Starting background workers...");
 
 
10
 
11
- try {
12
- // Start social automation worker
13
- await startSocialAutomationWorker();
 
 
 
14
 
15
- console.log("✅ All background workers started successfully");
16
- } catch (error) {
17
- console.error("❌ Error starting background workers:", error);
18
  }
 
 
 
 
19
  }
20
 
21
- // Auto-start workers in production
22
- if (process.env.NODE_ENV === "production" || process.env.START_WORKERS === "true") {
23
- startBackgroundWorkers();
 
 
 
 
 
24
  }
 
1
  /**
2
+ * Dedicated worker runtime.
3
+ * Only the worker process should call these functions.
4
  */
5
 
6
+ import { Logger } from "@/lib/logger";
7
+ import { startTriggerProcessor } from "@/lib/workflow-triggers";
8
+ import { startSocialAutomationWorker, stopSocialAutomationWorker } from "@/lib/workers/social-automation";
9
+ import { startScheduledPostWorker, stopScheduledPostWorker } from "@/lib/workers/scheduled-posts";
10
+
11
+ let started = false;
12
 
13
  export async function startBackgroundWorkers() {
14
+ if (started) {
15
+ return;
16
+ }
17
 
18
+ started = true;
19
+
20
+ Logger.info("Starting background automation services");
21
+
22
+ startTriggerProcessor();
23
+ startScheduledPostWorker();
24
 
25
+ if (process.env.ENABLE_SOCIAL_POLLING === "true") {
26
+ await startSocialAutomationWorker();
 
27
  }
28
+
29
+ Logger.info("Background automation services started", {
30
+ socialPolling: process.env.ENABLE_SOCIAL_POLLING === "true",
31
+ });
32
  }
33
 
34
+ export function stopBackgroundWorkers() {
35
+ stopScheduledPostWorker();
36
+
37
+ if (process.env.ENABLE_SOCIAL_POLLING === "true") {
38
+ stopSocialAutomationWorker();
39
+ }
40
+
41
+ started = false;
42
  }
lib/workers/scheduled-posts.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { and, eq, lte } from "drizzle-orm";
2
+
3
+ import { db } from "@/db";
4
+ import { connectedAccounts, socialPosts } from "@/db/schema";
5
+ import { claimSocialEvent, buildSocialEventKey, releaseSocialEvent } from "@/lib/social/event-dedupe";
6
+ import { socialPublisher } from "@/lib/social/publisher";
7
+ import { Logger } from "@/lib/logger";
8
+
9
+ const CHECK_INTERVAL_MS = 30 * 1000;
10
+
11
+ let intervalId: NodeJS.Timeout | null = null;
12
+ let isRunning = false;
13
+
14
+ async function publishScheduledPost(post: typeof socialPosts.$inferSelect) {
15
+ if (!post.connectedAccountId) {
16
+ await db
17
+ .update(socialPosts)
18
+ .set({
19
+ status: "failed",
20
+ error: "Scheduled post is missing a connected account",
21
+ updatedAt: new Date(),
22
+ })
23
+ .where(eq(socialPosts.id, post.id));
24
+ return;
25
+ }
26
+
27
+ const lockKey = buildSocialEventKey(post.platform, post.connectedAccountId, "scheduled-post", post.id);
28
+ const claimed = await claimSocialEvent(lockKey, 60 * 10);
29
+
30
+ if (!claimed) {
31
+ return;
32
+ }
33
+
34
+ try {
35
+ const account = await db.query.connectedAccounts.findFirst({
36
+ where: eq(connectedAccounts.id, post.connectedAccountId),
37
+ });
38
+
39
+ if (!account) {
40
+ throw new Error("Connected account not found");
41
+ }
42
+
43
+ await db
44
+ .update(socialPosts)
45
+ .set({
46
+ status: "publishing",
47
+ updatedAt: new Date(),
48
+ })
49
+ .where(and(eq(socialPosts.id, post.id), eq(socialPosts.status, "scheduled")));
50
+
51
+ const mediaUrl = post.mediaUrls?.[0];
52
+ const payload = {
53
+ content: post.content || post.title || "",
54
+ mediaUrl,
55
+ accessToken: account.accessToken,
56
+ providerAccountId: account.providerAccountId,
57
+ refreshToken: account.refreshToken || undefined,
58
+ };
59
+
60
+ let platformPostId: string | null = null;
61
+
62
+ if (account.provider === "facebook") {
63
+ platformPostId = await socialPublisher.publishToFacebook(payload);
64
+ } else if (account.provider === "instagram") {
65
+ platformPostId = await socialPublisher.publishToInstagram(payload);
66
+ } else if (account.provider === "linkedin") {
67
+ platformPostId = await socialPublisher.publishToLinkedin(payload);
68
+ } else if (account.provider === "youtube") {
69
+ platformPostId = (await socialPublisher.publishToYoutube(payload)) || null;
70
+ } else {
71
+ throw new Error(`Unsupported scheduled publishing provider: ${account.provider}`);
72
+ }
73
+
74
+ await db
75
+ .update(socialPosts)
76
+ .set({
77
+ status: "published",
78
+ platformPostId,
79
+ publishedAt: new Date(),
80
+ error: null,
81
+ updatedAt: new Date(),
82
+ })
83
+ .where(eq(socialPosts.id, post.id));
84
+
85
+ Logger.info("Scheduled post published", {
86
+ postId: post.id,
87
+ platform: account.provider,
88
+ platformPostId,
89
+ });
90
+ } catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+
93
+ await db
94
+ .update(socialPosts)
95
+ .set({
96
+ status: "failed",
97
+ error: message,
98
+ updatedAt: new Date(),
99
+ })
100
+ .where(eq(socialPosts.id, post.id));
101
+
102
+ Logger.error("Scheduled post publish failed", error, {
103
+ postId: post.id,
104
+ });
105
+ } finally {
106
+ await releaseSocialEvent(lockKey);
107
+ }
108
+ }
109
+
110
+ export async function processScheduledPostsOnce() {
111
+ const now = new Date();
112
+
113
+ const duePosts = await db.query.socialPosts.findMany({
114
+ where: and(
115
+ eq(socialPosts.status, "scheduled"),
116
+ lte(socialPosts.scheduledAt, now)
117
+ ),
118
+ limit: 25,
119
+ });
120
+
121
+ for (const post of duePosts) {
122
+ await publishScheduledPost(post);
123
+ }
124
+ }
125
+
126
+ export function startScheduledPostWorker(intervalMs = CHECK_INTERVAL_MS) {
127
+ if (isRunning) {
128
+ return;
129
+ }
130
+
131
+ isRunning = true;
132
+
133
+ processScheduledPostsOnce().catch((error) => {
134
+ Logger.error("Initial scheduled post run failed", error);
135
+ });
136
+
137
+ intervalId = setInterval(() => {
138
+ processScheduledPostsOnce().catch((error) => {
139
+ Logger.error("Scheduled post worker run failed", error);
140
+ });
141
+ }, intervalMs);
142
+
143
+ Logger.info("Scheduled post worker started", { intervalMs });
144
+ }
145
+
146
+ export function stopScheduledPostWorker() {
147
+ if (intervalId) {
148
+ clearInterval(intervalId);
149
+ intervalId = null;
150
+ }
151
+
152
+ isRunning = false;
153
+ }
package.json CHANGED
@@ -11,11 +11,11 @@
11
  "build:analyze": "ANALYZE=true next build",
12
  "start": "next start",
13
  "start:all": "tsx server.ts",
14
- "lint": "next lint",
15
  "db:generate": "drizzle-kit generate",
16
  "db:push": "drizzle-kit push",
17
  "db:studio": "drizzle-kit studio",
18
- "worker": "tsx workers.ts",
19
  "type-check": "tsc --noEmit",
20
  "postinstall": "npx puppeteer browsers install chrome",
21
  "log:build": "tsx scripts/view-logs.ts build",
 
11
  "build:analyze": "ANALYZE=true next build",
12
  "start": "next start",
13
  "start:all": "tsx server.ts",
14
+ "lint": "eslint .",
15
  "db:generate": "drizzle-kit generate",
16
  "db:push": "drizzle-kit push",
17
  "db:studio": "drizzle-kit studio",
18
+ "worker": "tsx scripts/worker.ts",
19
  "type-check": "tsc --noEmit",
20
  "postinstall": "npx puppeteer browsers install chrome",
21
  "log:build": "tsx scripts/view-logs.ts build",
scripts/worker.ts CHANGED
@@ -1,58 +1,56 @@
1
-
2
  /**
3
- * Dedicated Worker Entrypoint for Horizontal Scaling
4
- * Run this in a separate process/container to offload queue processing from the web server.
5
- * Usage: npx tsx scripts/worker.ts
6
  */
7
 
8
- import { startWorker } from "@/lib/queue";
9
- import { redis } from "@/lib/redis";
10
 
11
  async function main() {
12
- console.log("🚀 Starting dedicated worker process...");
13
-
14
- if (!redis) {
15
- console.error("❌ Redis is required for the worker.");
16
- process.exit(1);
17
- }
18
-
19
- // Load environment
20
- console.log(`🔌 Connected to DB and Redis at ${process.env.REDIS_URL}`);
21
-
22
- // Start the queue worker
23
- // Assuming startWorker is the function that initializes BullMQ workers
24
- // If queue.ts exports individual workers, we would initialize them here.
25
- // For this codebase, we import the existing queue initialization logic.
26
-
27
- try {
28
- await startWorker();
29
- console.log("✅ Worker started successfully. Listening for jobs...");
30
-
31
- // Self-ping optimization to keep server alive (every 5 minutes)
32
- const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:7860";
33
- console.log(`⏰ Setting up self-ping to ${APP_URL}/api/health every 5 minutes`);
34
-
35
- setInterval(async () => {
36
- try {
37
- const healthUrl = `${APP_URL}/api/health`;
38
- // Use fetch to ping the health endpoint
39
- // We don't care about the response, just hitting the route
40
- await fetch(healthUrl);
41
- // Optional: console.log(`pinged ${healthUrl}`);
42
- } catch (pingError) {
43
- console.error("Self-ping failed:", pingError);
44
- }
45
- }, 5 * 60 * 1000); // 5 minutes
46
- } catch (err: unknown) {
47
- console.error("❌ Failed to start worker:", err);
48
- process.exit(1);
49
- }
50
-
51
- // Keep process alive
52
- process.on('SIGTERM', async () => {
53
- console.log('🛑 Worker received SIGTERM, shutting down...');
54
- process.exit(0);
55
- });
56
  }
57
 
58
- main();
 
 
 
 
 
1
  /**
2
+ * Dedicated worker entrypoint.
3
+ * Run this in a separate process/container to handle queue jobs and background automation.
 
4
  */
5
 
6
+ process.env.START_QUEUE_WORKERS = "true";
 
7
 
8
  async function main() {
9
+ const { connectRedis } = await import("@/lib/redis");
10
+
11
+ console.log("Starting dedicated worker process...");
12
+
13
+ const redis = await connectRedis();
14
+
15
+ if (!redis) {
16
+ console.error("Redis/Valkey is required for the worker process.");
17
+ process.exit(1);
18
+ }
19
+
20
+ try {
21
+ await redis.ping();
22
+ } catch (error) {
23
+ console.error("Queue backend is unreachable:", error);
24
+ process.exit(1);
25
+ }
26
+
27
+ const { startWorker } = await import("@/lib/queue");
28
+ const { startBackgroundWorkers, stopBackgroundWorkers } = await import("@/lib/workers");
29
+
30
+ try {
31
+ const workers = await startWorker();
32
+ await startBackgroundWorkers();
33
+
34
+ console.log("Worker process is online.");
35
+
36
+ const shutdown = async () => {
37
+ console.log("Stopping worker process...");
38
+ stopBackgroundWorkers();
39
+ await workers.emailWorker.close();
40
+ await workers.scrapingWorker.close();
41
+ await workers.workflowWorker.close();
42
+ process.exit(0);
43
+ };
44
+
45
+ process.on("SIGTERM", shutdown);
46
+ process.on("SIGINT", shutdown);
47
+ } catch (error) {
48
+ console.error("Failed to start worker:", error);
49
+ process.exit(1);
50
+ }
 
 
51
  }
52
 
53
+ main().catch((error) => {
54
+ console.error("Worker bootstrap failed:", error);
55
+ process.exit(1);
56
+ });
server.ts CHANGED
@@ -1,171 +1,49 @@
1
  import "dotenv/config";
 
2
  import { createServer } from "http";
3
- import { spawn } from "child_process";
4
- import { parse } from "url";
5
- import { Socket } from "net";
6
  import next from "next";
 
 
7
  import { validateEnvironmentVariables } from "./lib/validate-env";
8
 
9
- // Ignore "frame list" errors (often from ws/zlib in dev environments)
10
  process.on("uncaughtException", (err) => {
11
- if (err.message && err.message.includes("frame list")) {
12
- // Safe to ignore
13
- return;
14
- }
15
- console.error("Uncaught Exception:", err);
16
- process.exit(1);
17
  });
18
 
19
  const dev = process.env.NODE_ENV !== "production";
20
- console.log(`[Server] NODE_ENV: ${process.env.NODE_ENV}`);
21
- console.log(`[Server] Is Dev Mode: ${dev}`);
22
-
23
  const hostname = "0.0.0.0";
24
  const port = parseInt(process.env.PORT || "7860", 10);
25
 
26
- // Initialize Next.js app
27
- const app = next({ dev, hostname, port });
28
- const handle = app.getRequestHandler();
29
-
30
- // Trigger processor import - also dynamic later? No, this one is fine but safer to move inside
31
- // import { startTriggerProcessor } from "./lib/workflow-triggers";
32
-
33
- console.log("🚀 Starting Custom Server (Next.js + Workers)...");
34
 
35
  validateEnvironmentVariables();
36
 
37
- app.prepare().then(async () => {
38
- // 1. Try to start Local Redis (if available and not connecting to external)
39
- // Default to localhost if REDIS_URL is not set
40
- const useLocalRedis = !process.env.REDIS_URL || process.env.REDIS_URL.includes("localhost") || process.env.REDIS_URL.includes("127.0.0.1");
41
-
42
- if (useLocalRedis) {
43
- // Force the env var so the app connects to it securely
44
- if (!process.env.REDIS_URL) {
45
- process.env.REDIS_URL = "redis://localhost:6379";
46
- }
47
-
48
- console.log(`🔄 Checking for local Redis on ${process.env.REDIS_URL}...`);
49
-
50
- // Check if Redis is already running
51
- const isRedisRunning = await new Promise<boolean>((resolve) => {
52
- const client = new Socket();
53
- const redisUrl = new URL(process.env.REDIS_URL!);
54
- const port = parseInt(redisUrl.port || "6379");
55
- const host = redisUrl.hostname || "localhost";
56
-
57
- client.connect(port, host, () => {
58
- client.end();
59
- resolve(true);
60
- });
61
-
62
- client.on("error", () => {
63
- resolve(false);
64
- });
65
- });
66
-
67
- if (isRedisRunning) {
68
- console.log("✅ Local Redis service detected. Skipping auto-spawn.");
69
- } else {
70
- console.log(`🔄 Attempting to start local Redis server...`);
71
- try {
72
- // Check if redis-server is in PATH (simple spawn attempt)
73
- const redisProcess = spawn("redis-server", [], {
74
- stdio: "ignore", // Run in background
75
- detached: true,
76
- shell: true // Try with shell=true for better window path handling
77
- });
78
-
79
- redisProcess.on("error", (err) => {
80
- console.warn(`⚠️ Could not auto-start 'redis-server': ${err.message}`);
81
- console.warn("⚠️ App will proceed, but background jobs may fail if Redis is not running.");
82
- });
83
-
84
- if (redisProcess.pid) {
85
- console.log(`✅ Started local Redis process (PID: ${redisProcess.pid})`);
86
- redisProcess.unref();
87
-
88
- process.on("exit", () => {
89
- try { process.kill(redisProcess.pid!); } catch { }
90
- });
91
- }
92
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
93
- } catch (spawnError) {
94
- console.warn("⚠️ Failed to spawn redis-server. Make sure it is installed.");
95
- }
96
- }
97
- }
98
-
99
- // Dynamic Import of Workers/Triggers AFTER Redis env/process is handled
100
- console.log("👷 Initializing Background Workers...");
101
-
102
- // Start Triggers
103
- if (process.env.NODE_ENV === "production" || process.env.ENABLE_TRIGGERS === "true") {
104
- import("./lib/workflow-triggers").then(({ startTriggerProcessor }) => {
105
- startTriggerProcessor();
106
- });
107
- }
108
 
109
- // Start Workers
 
110
  try {
111
- const { emailWorker, scrapingWorker } = await import("./lib/queue");
112
- console.log(`✅ Email Worker ID: ${emailWorker.id}`);
113
- console.log(`✅ Scraping Worker ID: ${scrapingWorker.id}`);
114
-
115
- // Graceful Shutdown
116
- const shutdown = async () => {
117
- console.log("🛑 Shutting down server and workers...");
118
- await emailWorker.close();
119
- await scrapingWorker.close();
120
- process.exit(0);
121
- };
122
-
123
- process.on("SIGTERM", shutdown);
124
- process.on("SIGINT", shutdown);
125
-
126
- } catch (e) {
127
- console.error("❌ Failed to initialize workers (Redis likely missing):", e);
128
- // Do not crash server, just run without workers
129
  }
130
-
131
- // 2. Start HTTP Server
132
- createServer(async (req, res) => {
133
- try {
134
- const parsedUrl = parse(req.url!, true);
135
- await handle(req, res, parsedUrl);
136
- } catch (err) {
137
- console.error("Error occurred handling", req.url, err);
138
- res.statusCode = 500;
139
- res.end("internal server error");
140
- }
141
  })
142
- .once("error", (err) => {
143
- console.error(err);
144
- process.exit(1);
145
- })
146
- .listen(port, () => {
147
- console.log(`> Ready on http://${hostname}:${port}`);
148
- });
149
-
150
- // 3. Keep-Alive Mechanism
151
- const KEEP_ALIVE_INTERVAL = 14 * 60 * 1000; // 14 minutes
152
-
153
- if (process.env.ENABLE_KEEP_ALIVE === "true") {
154
- console.log("💓 Keep-Alive mechanism enabled");
155
-
156
- const performHealthCheck = async () => {
157
- try {
158
- const url = `http://${hostname}:${port}/api/health`;
159
- // console.log(`💓 Sending keep-alive ping to ${url}`);
160
- const response = await fetch(url);
161
- await response.json();
162
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
163
- } catch (error) {
164
- // Silent catch
165
- }
166
- };
167
-
168
- setTimeout(performHealthCheck, 30000);
169
- setInterval(performHealthCheck, KEEP_ALIVE_INTERVAL);
170
- }
171
  });
 
1
  import "dotenv/config";
2
+
3
  import { createServer } from "http";
 
 
 
4
  import next from "next";
5
+ import { parse } from "url";
6
+
7
  import { validateEnvironmentVariables } from "./lib/validate-env";
8
 
 
9
  process.on("uncaughtException", (err) => {
10
+ if (err.message && err.message.includes("frame list")) {
11
+ return;
12
+ }
13
+
14
+ console.error("Uncaught Exception:", err);
15
+ process.exit(1);
16
  });
17
 
18
  const dev = process.env.NODE_ENV !== "production";
 
 
 
19
  const hostname = "0.0.0.0";
20
  const port = parseInt(process.env.PORT || "7860", 10);
21
 
22
+ console.log(`[Server] NODE_ENV: ${process.env.NODE_ENV}`);
23
+ console.log(`[Server] Is Dev Mode: ${dev}`);
24
+ console.log("Starting AutoLoop web server...");
 
 
 
 
 
25
 
26
  validateEnvironmentVariables();
27
 
28
+ const app = next({ dev, hostname, port });
29
+ const handle = app.getRequestHandler();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ app.prepare().then(() => {
32
+ createServer(async (req, res) => {
33
  try {
34
+ const parsedUrl = parse(req.url!, true);
35
+ await handle(req, res, parsedUrl);
36
+ } catch (err) {
37
+ console.error("Error occurred handling", req.url, err);
38
+ res.statusCode = 500;
39
+ res.end("internal server error");
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
+ })
42
+ .once("error", (err) => {
43
+ console.error(err);
44
+ process.exit(1);
 
 
 
 
 
 
 
45
  })
46
+ .listen(port, () => {
47
+ console.log(`> Ready on http://${hostname}:${port}`);
48
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  });
start.sh CHANGED
@@ -1,13 +1,63 @@
1
  #!/bin/bash
2
 
3
- # Start Redis in the background
4
- echo "🚀 Starting Local Redis..."
5
- redis-server --daemonize yes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- # Wait a moment for Redis to initialize
8
  sleep 2
9
 
10
- # Start the Next.js App in production mode + Workers
11
- echo "🚀 Starting Production App + Workers..."
12
- # Use tsx with production environment
13
- exec npx tsx server.ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/bin/bash
2
 
3
+ set -euo pipefail
4
+
5
+ QUEUE_DIR="${QUEUE_DIR:-/data/valkey}"
6
+ QUEUE_CONFIG="/tmp/valkey.conf"
7
+ QUEUE_BIN="valkey-server"
8
+ QUEUE_CLI="valkey-cli"
9
+
10
+ if ! command -v "${QUEUE_BIN}" >/dev/null 2>&1; then
11
+ QUEUE_BIN="redis-server"
12
+ QUEUE_CLI="redis-cli"
13
+ fi
14
+
15
+ if [ ! -d "$(dirname "${QUEUE_DIR}")" ] || [ ! -w "$(dirname "${QUEUE_DIR}")" ]; then
16
+ QUEUE_DIR="/tmp/valkey"
17
+ fi
18
+
19
+ mkdir -p "${QUEUE_DIR}"
20
+
21
+ if [ -z "${REDIS_URL:-}" ]; then
22
+ export REDIS_URL="redis://127.0.0.1:6379"
23
+ fi
24
+
25
+ cat > "${QUEUE_CONFIG}" <<EOF
26
+ bind 127.0.0.1
27
+ port 6379
28
+ dir ${QUEUE_DIR}
29
+ appendonly yes
30
+ save 60 1000
31
+ daemonize yes
32
+ EOF
33
+
34
+ echo "Starting local queue backend with ${QUEUE_BIN}..."
35
+ "${QUEUE_BIN}" "${QUEUE_CONFIG}"
36
 
 
37
  sleep 2
38
 
39
+ cleanup() {
40
+ if [ -n "${WORKER_PID:-}" ] && kill -0 "${WORKER_PID}" >/dev/null 2>&1; then
41
+ kill "${WORKER_PID}" >/dev/null 2>&1 || true
42
+ fi
43
+
44
+ if [ -n "${SERVER_PID:-}" ] && kill -0 "${SERVER_PID}" >/dev/null 2>&1; then
45
+ kill "${SERVER_PID}" >/dev/null 2>&1 || true
46
+ fi
47
+
48
+ if command -v "${QUEUE_CLI}" >/dev/null 2>&1; then
49
+ "${QUEUE_CLI}" shutdown nosave >/dev/null 2>&1 || true
50
+ fi
51
+ }
52
+
53
+ trap cleanup EXIT INT TERM
54
+
55
+ echo "Starting dedicated automation worker..."
56
+ pnpm worker &
57
+ WORKER_PID=$!
58
+
59
+ echo "Starting web server..."
60
+ pnpm start:all &
61
+ SERVER_PID=$!
62
+
63
+ wait -n "${WORKER_PID}" "${SERVER_PID}"
types/social-workflow.ts CHANGED
@@ -159,7 +159,9 @@ export type SocialTriggerType =
159
  | 'comment_keyword'
160
  | 'dm_keyword'
161
  | 'story_mention'
162
- | 'any_comment';
 
 
163
 
164
  export type SocialActionType =
165
  | 'reply_comment'
 
159
  | 'comment_keyword'
160
  | 'dm_keyword'
161
  | 'story_mention'
162
+ | 'any_comment'
163
+ | 'whatsapp_keyword'
164
+ | 'whatsapp_command';
165
 
166
  export type SocialActionType =
167
  | 'reply_comment'
workers.ts DELETED
@@ -1,29 +0,0 @@
1
- /**
2
- * Worker initialization file
3
- * Start the BullMQ workers for background job processing
4
- *
5
- * Run this in a separate process: node workers.js
6
- */
7
-
8
- import { emailWorker, scrapingWorker } from "./lib/queue";
9
-
10
- console.log("🚀 Starting BullMQ workers...");
11
- console.log("📧 Email worker: Ready");
12
- console.log("🔍 Scraping worker: Ready");
13
-
14
- // Graceful shutdown
15
- process.on("SIGTERM", async () => {
16
- console.log("⏸️ Stopping workers...");
17
- await emailWorker.close();
18
- await scrapingWorker.close();
19
- process.exit(0);
20
- });
21
-
22
- process.on("SIGINT", async () => {
23
- console.log("⏸️ Stopping workers...");
24
- await emailWorker.close();
25
- await scrapingWorker.close();
26
- process.exit(0);
27
- });
28
-
29
- console.log("✅ Workers are running. Press Ctrl+C to stop.");