Skip to content

Commit 48a7258

Browse files
committed
feat: Implement comprehensive competitor analysis for tech stack, branding, and performance with dedicated APIs, alerts, and analytics proxy.
1 parent 7b0ce6f commit 48a7258

26 files changed

Lines changed: 4392 additions & 16 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,10 @@ OPENAI_API_KEY=sk-your_openai_api_key
2323
RESEND_API_KEY=re_your_resend_api_key
2424
RESEND_FROM_EMAIL=RivalEye <alerts@yourdomain.com>
2525

26+
# Analytics
27+
NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here
28+
NEXT_PUBLIC_POSTHOG_HOST=https://rivaleye.com/ingress
29+
NEXT_PUBLIC_CLOUDFLARE_BEACON_TOKEN=your_cloudflare_beacon_token_here
30+
2631
# Cron Security (generate a random string)
2732
CRON_SECRET=your_random_secret_for_cron_jobs

.github/workflows/deploy.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Deploy RivalEye Stack
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- "workers/**"
9+
- "src/trigger/**"
10+
- ".github/workflows/deploy.yml"
11+
12+
jobs:
13+
deploy-cloudflare:
14+
name: Deploy Analytics Proxy (Cloudflare)
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
20+
- name: Deploy Worker
21+
uses: cloudflare/wrangler-action@v3
22+
with:
23+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
24+
workingDirectory: "workers/analytics-proxy"
25+
26+
deploy-trigger:
27+
name: Deploy Tasks (Trigger.dev)
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@v4
32+
33+
- name: Setup Node
34+
uses: actions/setup-node@v4
35+
with:
36+
node-version: 20
37+
cache: "npm"
38+
39+
- name: Install dependencies
40+
run: npm install
41+
42+
- name: Deploy to Trigger.dev
43+
run: npx trigger.dev@latest deploy --prod
44+
env:
45+
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}

.gitignore

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ yarn-error.log*
4141
*.tsbuildinfo
4242
next-env.d.ts
4343

44-
docs
44+
# docs
4545
.agent/
46-
supabase
46+
supabase/
4747
debug/
48-
migrations
48+
migrations
49+
manual
50+
docs

package-lock.json

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"next": "^16.0.3",
3030
"next-themes": "^0.4.6",
3131
"playwright": "^1.57.0",
32+
"posthog-js": "^1.313.0",
3233
"react": "^19.2.0",
3334
"react-dom": "^19.2.0",
3435
"resend": "^6.6.0",
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getUserId } from "@/lib/auth";
3+
import { createServerClient } from "@/lib/supabase";
4+
import { getFeatureFlags } from "@/lib/billing/featureFlags";
5+
import { extractBranding, compareBranding, type ExtractedBranding } from "@/lib/crawler/brandingExtractor";
6+
import { analyzeBrandingChanges, createBrandingAlerts } from "@/lib/alerts/brandingAlerts";
7+
8+
/**
9+
* Branding Extraction API
10+
*
11+
* GET - Retrieve stored branding for a competitor
12+
* POST - Trigger branding extraction (Pro feature)
13+
*/
14+
15+
export async function GET(
16+
request: NextRequest,
17+
{ params }: { params: Promise<{ id: string }> }
18+
) {
19+
try {
20+
const userId = await getUserId();
21+
if (!userId) {
22+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
23+
}
24+
25+
const { id: competitorId } = await params;
26+
const supabase = createServerClient();
27+
28+
// Verify competitor ownership
29+
const { data: competitor } = await supabase
30+
.from("competitors")
31+
.select("id, name, url")
32+
.eq("id", competitorId)
33+
.eq("user_id", userId)
34+
.single();
35+
36+
if (!competitor) {
37+
return NextResponse.json({ error: "Competitor not found" }, { status: 404 });
38+
}
39+
40+
// Get stored branding data
41+
const { data: branding } = await supabase
42+
.from("competitor_branding")
43+
.select("*")
44+
.eq("competitor_id", competitorId)
45+
.order("extracted_at", { ascending: false })
46+
.limit(1)
47+
.single();
48+
49+
if (!branding) {
50+
return NextResponse.json({
51+
branding: null,
52+
message: "No branding data available. Trigger extraction with POST.",
53+
});
54+
}
55+
56+
return NextResponse.json({
57+
branding: branding.branding_data,
58+
extractedAt: branding.extracted_at,
59+
competitorName: competitor.name,
60+
});
61+
} catch (error) {
62+
console.error("[Branding API] GET error:", error);
63+
return NextResponse.json({ error: "Failed to fetch branding" }, { status: 500 });
64+
}
65+
}
66+
67+
export async function POST(
68+
request: NextRequest,
69+
{ params }: { params: Promise<{ id: string }> }
70+
) {
71+
try {
72+
const userId = await getUserId();
73+
if (!userId) {
74+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
75+
}
76+
77+
const { id: competitorId } = await params;
78+
const supabase = createServerClient();
79+
80+
// Get user plan
81+
const { data: user } = await supabase
82+
.from("users")
83+
.select("plan")
84+
.eq("id", userId)
85+
.single();
86+
87+
// Check Pro feature access
88+
const flags = getFeatureFlags(user?.plan || "free");
89+
if (!flags.canViewAiInsights) {
90+
return NextResponse.json(
91+
{
92+
error: "Branding extraction is a Pro feature",
93+
upgradeRequired: true,
94+
},
95+
{ status: 403 }
96+
);
97+
}
98+
99+
// Verify competitor ownership
100+
const { data: competitor } = await supabase
101+
.from("competitors")
102+
.select("id, name, url")
103+
.eq("id", competitorId)
104+
.eq("user_id", userId)
105+
.single();
106+
107+
if (!competitor) {
108+
return NextResponse.json({ error: "Competitor not found" }, { status: 404 });
109+
}
110+
111+
// Extract branding
112+
console.log(`[Branding API] Extracting branding for: ${competitor.url}`);
113+
const result = await extractBranding(competitor.url);
114+
115+
if (!result.success) {
116+
return NextResponse.json(
117+
{ error: result.error, code: result.code },
118+
{ status: 422 }
119+
);
120+
}
121+
122+
// Get previous branding for comparison
123+
const { data: previousBranding } = await supabase
124+
.from("competitor_branding")
125+
.select("branding_data")
126+
.eq("competitor_id", competitorId)
127+
.order("extracted_at", { ascending: false })
128+
.limit(1)
129+
.single();
130+
131+
// Compare with previous if exists and create alerts
132+
let diff = null;
133+
let alerts: ReturnType<typeof analyzeBrandingChanges> = [];
134+
if (previousBranding?.branding_data) {
135+
const oldBranding = previousBranding.branding_data as ExtractedBranding;
136+
diff = compareBranding(oldBranding, result.branding);
137+
138+
// Analyze changes and create semantic alerts
139+
alerts = analyzeBrandingChanges(oldBranding, result.branding);
140+
if (alerts.length > 0) {
141+
await createBrandingAlerts(userId, competitorId, competitor.name, alerts);
142+
console.log(`[Branding API] Created ${alerts.length} branding alerts`);
143+
}
144+
}
145+
146+
// Store new branding
147+
const { error: insertError } = await supabase
148+
.from("competitor_branding")
149+
.insert({
150+
competitor_id: competitorId,
151+
branding_data: result.branding,
152+
extracted_at: new Date().toISOString(),
153+
});
154+
155+
if (insertError) {
156+
console.error("[Branding API] Insert error:", insertError);
157+
}
158+
159+
return NextResponse.json({
160+
success: true,
161+
branding: result.branding,
162+
diff,
163+
alerts: alerts.length > 0 ? alerts : null,
164+
competitorName: competitor.name,
165+
});
166+
} catch (error) {
167+
console.error("[Branding API] POST error:", error);
168+
return NextResponse.json({ error: "Failed to extract branding" }, { status: 500 });
169+
}
170+
}

0 commit comments

Comments
 (0)