fix broken automation
Browse files- .cursorrules +14 -0
- .gemini/GEMINI.md +14 -0
- .github/copilot-instructions.md +14 -0
- .vscode/tasks.json +74 -0
- .zed/settings.json +16 -0
- AGENTS.md +14 -0
- CLAUDE.md +14 -0
- Dockerfile +4 -3
- app/api/auth/otp/send/route.ts +3 -1
- app/api/health/route.ts +3 -1
- app/api/performance/metrics/route.ts +2 -0
- app/api/social/automations/create/route.ts +22 -6
- app/api/social/automations/route.ts +12 -2
- app/api/social/callback/[provider]/route.ts +86 -30
- app/api/social/connect/[provider]/route.ts +5 -0
- app/api/social/posts/create/route.ts +7 -4
- app/api/social/posts/route.ts +11 -15
- app/api/social/webhooks/facebook/route.ts +274 -205
- app/api/webhooks/facebook/route.ts +1 -125
- app/dashboard/businesses/page.tsx +1 -6
- app/dashboard/social/automations/new/page.tsx +243 -129
- app/dashboard/social/automations/page.tsx +34 -7
- components/performance-dashboard.tsx +80 -61
- components/settings/social-settings.tsx +1 -1
- lib/auth.ts +2 -1
- lib/cache-manager.ts +11 -1
- lib/environment-config.ts +1 -1
- lib/performance-monitoring.ts +89 -106
- lib/queue.ts +553 -539
- lib/rate-limit.ts +10 -1
- lib/redis.ts +91 -39
- lib/social/event-dedupe.ts +45 -0
- lib/validations.ts +9 -2
- lib/whatsapp/client.ts +4 -2
- lib/whatsapp/webhook.ts +86 -79
- lib/workers/index.ts +31 -13
- lib/workers/scheduled-posts.ts +153 -0
- package.json +2 -2
- scripts/worker.ts +49 -51
- server.ts +29 -151
- start.sh +58 -8
- types/social-workflow.ts +3 -1
- workers.ts +0 -29
.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
|
| 4 |
RUN apt-get update && apt-get install -y \
|
| 5 |
chromium \
|
| 6 |
git \
|
| 7 |
-
|
|
|
|
| 8 |
# Dependencies for Puppeteer
|
| 9 |
gconf-service \
|
| 10 |
libasound2 \
|
|
@@ -86,5 +87,5 @@ ENV PORT=7860
|
|
| 86 |
EXPOSE 7860
|
| 87 |
|
| 88 |
|
| 89 |
-
# Start
|
| 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 {
|
| 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 {
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
if (!name || !triggerType || !actionType || !responseTemplate) {
|
| 18 |
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
let accountId = null;
|
| 23 |
|
| 24 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const account = await db.query.connectedAccounts.findFirst({
|
| 26 |
-
where:
|
|
|
|
|
|
|
|
|
|
| 27 |
});
|
| 28 |
|
| 29 |
if (!account) {
|
| 30 |
-
return NextResponse.json({ error: "
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 82 |
-
const
|
| 83 |
-
const
|
| 84 |
-
const
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
where: and(
|
| 90 |
-
eq(connectedAccounts.userId, userId),
|
| 91 |
-
eq(connectedAccounts.provider, "facebook")
|
| 92 |
-
)
|
| 93 |
-
});
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 105 |
-
const provider = account.provider as any;
|
| 106 |
-
|
| 107 |
-
if (provider === "facebook") {
|
| 108 |
platformPostId = await socialPublisher.publishToFacebook(payload);
|
| 109 |
-
} else if (provider === "instagram") {
|
| 110 |
-
|
| 111 |
-
|
| 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 {
|
| 9 |
-
import {
|
| 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 |
-
|
| 19 |
-
const token = searchParams.get("hub.verify_token");
|
| 20 |
-
const challenge = searchParams.get("hub.challenge");
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
return NextResponse.json({ success: false }, { status: 200 });
|
| 74 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
-
const
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
}
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
console.log(`@️ Mention event for page ${pageId}`);
|
| 175 |
-
// Could trigger automations based on mentions
|
| 176 |
}
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
replyText: string,
|
| 192 |
-
accessToken: string,
|
| 193 |
-
provider: string
|
| 194 |
) {
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 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 |
-
|
| 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
|
| 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 {
|
| 11 |
-
import { useRouter } from "next/navigation";
|
| 12 |
-
import { Loader2, ArrowLeft } from "lucide-react";
|
| 13 |
-
import Link from "next/link";
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
export default function NewAutomationPage() {
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
<
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 {
|
| 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 |
-
|
| 10 |
-
|
| 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<
|
| 28 |
const [vitalsStatus, setVitalsStatus] = useState<WebVitalsStatus | null>(null);
|
| 29 |
-
const [apiMetrics, setApiMetrics] = useState<
|
| 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 |
-
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 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="
|
| 74 |
}
|
|
|
|
| 75 |
if (status === "poor") {
|
| 76 |
-
return <AlertCircle className="
|
| 77 |
}
|
| 78 |
-
|
|
|
|
| 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((_,
|
| 86 |
-
<Card key={
|
| 87 |
<CardHeader className="space-y-2">
|
| 88 |
-
<div className="h-4 w-24
|
| 89 |
-
<div className="h-8 w-32
|
| 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 (
|
| 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 (
|
| 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 (
|
| 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 (>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="
|
| 230 |
<ResponsiveContainer width="100%" height="100%">
|
| 231 |
-
<BarChart data={
|
| 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="
|
| 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="
|
| 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="
|
| 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 (>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/
|
| 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 {
|
|
|
|
| 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 {
|
| 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 =
|
| 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,
|
| 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)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
|
| 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 (
|
| 64 |
-
|
| 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 ==
|
| 81 |
-
|
| 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 |
-
|
| 134 |
-
|
| 135 |
-
|
| 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,
|
| 143 |
-
this.apiMetrics.length
|
| 144 |
: 0;
|
| 145 |
|
| 146 |
-
const slowRequests = this.apiMetrics.filter((
|
| 147 |
-
const cachedRequests = this.apiMetrics.filter((
|
| 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<
|
| 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
|
| 4 |
-
import {
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
interface EmailJobData {
|
| 25 |
userId: string;
|
|
@@ -37,28 +34,89 @@ interface ScrapingJobData {
|
|
| 37 |
sources?: ScraperSourceName[];
|
| 38 |
}
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
export async function queueEmail(data: EmailJobData) {
|
| 44 |
-
await
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
.select()
|
| 81 |
-
.from(businesses)
|
| 82 |
-
.where(eq(businesses.id, businessId))
|
| 83 |
-
.limit(1);
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 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 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
userId,
|
| 143 |
businessId: business.id,
|
| 144 |
templateId: template.id,
|
| 145 |
subject: interpolateTemplate(template.subject, business, sender),
|
| 146 |
-
body: "",
|
| 147 |
status: "pending",
|
| 148 |
errorMessage: null,
|
| 149 |
sentAt: null,
|
| 150 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
template,
|
| 177 |
-
accessToken,
|
| 178 |
-
sender
|
| 179 |
-
);
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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)
|
| 250 |
-
|
| 251 |
-
|
| 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 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
.from(scrapingJobs)
|
| 272 |
-
.where(eq(scrapingJobs.id, jobId))
|
| 273 |
-
.limit(1);
|
| 274 |
|
| 275 |
-
|
|
|
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
}
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
}
|
| 289 |
|
| 290 |
-
|
| 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 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
|
| 347 |
-
|
| 348 |
-
|
| 349 |
}
|
| 350 |
|
| 351 |
-
|
| 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 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
| 387 |
|
|
|
|
|
|
|
| 388 |
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
userId: string;
|
| 397 |
-
businessId: string;
|
| 398 |
-
executionId: string;
|
| 399 |
}
|
| 400 |
|
| 401 |
-
|
| 402 |
-
|
| 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 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
|
| 441 |
-
|
|
|
|
|
|
|
| 442 |
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
|
| 447 |
-
|
|
|
|
|
|
|
| 448 |
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
})
|
| 488 |
-
.where(eq(automationWorkflows.id, workflowId));
|
| 489 |
-
} else {
|
| 490 |
-
throw new Error("Workflow execution logic returned failure");
|
| 491 |
-
}
|
| 492 |
|
| 493 |
-
|
|
|
|
|
|
|
| 494 |
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
|
|
|
| 498 |
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
status: "failed",
|
| 504 |
-
error: msg,
|
| 505 |
-
completedAt: new Date(),
|
| 506 |
-
})
|
| 507 |
-
.where(eq(workflowExecutionLogs.id, executionId));
|
| 508 |
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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: `
|
| 551 |
});
|
| 552 |
-
console.log(`📱 WhatsApp Alert sent to ${user.phone}`);
|
| 553 |
}
|
| 554 |
-
} catch (
|
| 555 |
-
console.error("Failed to send WhatsApp alert:",
|
| 556 |
}
|
| 557 |
-
}
|
| 558 |
-
}
|
| 559 |
|
|
|
|
|
|
|
| 560 |
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
const
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
}
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 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 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
};
|
| 606 |
}
|
| 607 |
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
|
|
|
|
|
|
|
|
|
|
| 617 |
|
|
|
|
| 618 |
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 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 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
})
|
| 644 |
-
(
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
|
| 657 |
return {
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
};
|
| 662 |
-
} catch (
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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
|
| 4 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export function getRedis(): Redis | null {
|
| 7 |
-
if (
|
| 8 |
-
return
|
| 9 |
}
|
| 10 |
|
|
|
|
|
|
|
| 11 |
try {
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
return null;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
return null; // Stop retrying
|
| 30 |
-
}
|
| 31 |
-
return Math.min(times * 50, 200);
|
| 32 |
-
},
|
| 33 |
-
lazyConnect: true,
|
| 34 |
-
enableOfflineQueue: false,
|
| 35 |
-
});
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
console.warn("Redis connection failed:", err.message);
|
| 40 |
-
redis = null;
|
| 41 |
-
});
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
} catch (error) {
|
| 49 |
-
console.warn(
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return null;
|
| 52 |
}
|
| 53 |
}
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
| 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([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ==
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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
|
| 83 |
-
.
|
| 84 |
-
.
|
| 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;
|
| 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 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
text = `[${type} message]`;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
console.log(`📩 WhatsApp Message from ${senderPhone}: ${text}`);
|
| 108 |
|
| 109 |
-
// 1. Log Inbound Message
|
| 110 |
try {
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
} catch (error) {
|
| 123 |
-
console.error("Failed to log inbound message:", error);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
} else if (rule.triggerType === "whatsapp_command") {
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
-
if (matched) {
|
| 149 |
-
|
|
|
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 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 |
-
*
|
| 3 |
-
*
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export async function startBackgroundWorkers() {
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
console.error("❌ Error starting background workers:", error);
|
| 18 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 15 |
"db:generate": "drizzle-kit generate",
|
| 16 |
"db:push": "drizzle-kit push",
|
| 17 |
"db:studio": "drizzle-kit studio",
|
| 18 |
-
"worker": "tsx
|
| 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
|
| 4 |
-
* Run this in a separate process/container to
|
| 5 |
-
* Usage: npx tsx scripts/worker.ts
|
| 6 |
*/
|
| 7 |
|
| 8 |
-
|
| 9 |
-
import { redis } from "@/lib/redis";
|
| 10 |
|
| 11 |
async function main() {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
/
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
process.
|
| 53 |
-
|
| 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 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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
|
| 38 |
-
|
| 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 |
-
|
|
|
|
| 110 |
try {
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
# Wait a moment for Redis to initialize
|
| 8 |
sleep 2
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|