refactor: flatten monorepo structure to backend/ frontend/ devops/
Rename subdirectories for a cleaner single-repo layout: - website-monitoring-backend/ → backend/ - website-monitoring-frontend/ → frontend/ - website-monitoring-devops/ → devops/ Update all references in package.json scripts, CI workflows, docker-compose, pre-commit hooks, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/organizations
|
||||
*
|
||||
* List all organizations with usage stats.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: orgs, count, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("*", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Enrich with counts
|
||||
const enrichedOrgs = await Promise.all(
|
||||
(orgs || []).map(async (org) => {
|
||||
const orgId = org.id as string;
|
||||
const [
|
||||
{ count: memberCount },
|
||||
{ count: websiteCount },
|
||||
{ count: scanCount },
|
||||
] = await Promise.all([
|
||||
supabase.from("organization_members").select("*", { count: "exact", head: true }).eq("organization_id", orgId),
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }).eq("organization_id", orgId),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...org,
|
||||
memberCount: memberCount || 0,
|
||||
websiteCount: websiteCount || 0,
|
||||
scanCount: scanCount || 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: enrichedOrgs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
totalPages: Math.ceil((count || 0) / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/organizations
|
||||
*
|
||||
* Update organization: change tier, deactivate, etc.
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { organizationId, updates } = await request.json();
|
||||
|
||||
if (!organizationId || !updates) {
|
||||
return NextResponse.json(
|
||||
{ error: "organizationId and updates required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update(updates)
|
||||
.eq("id", organizationId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true, message: "Organization updated" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
*
|
||||
* Returns system-wide statistics for the admin dashboard.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Parallel queries for stats
|
||||
const [
|
||||
{ count: totalUsers },
|
||||
{ count: totalOrgs },
|
||||
{ count: totalWebsites },
|
||||
{ count: totalScans },
|
||||
{ count: scansToday },
|
||||
{ count: scansThisMonth },
|
||||
{ count: activeAlerts },
|
||||
{ count: uptimeChecks24h },
|
||||
] = await Promise.all([
|
||||
supabase.from("users").select("*", { count: "exact", head: true }),
|
||||
supabase.from("organizations").select("*", { count: "exact", head: true }),
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }).gte("created_at", last24h),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }).gte("created_at", last30d),
|
||||
supabase.from("alerts").select("*", { count: "exact", head: true }).eq("status", "active"),
|
||||
supabase.from("uptime_checks").select("*", { count: "exact", head: true }).gte("checked_at", last24h),
|
||||
]);
|
||||
|
||||
// Get uptime summary
|
||||
const { data: uptimeDown } = await supabase
|
||||
.from("uptime_checks")
|
||||
.select("id")
|
||||
.eq("status", "down")
|
||||
.gte("checked_at", last24h);
|
||||
|
||||
const uptimeUpPercentage =
|
||||
uptimeChecks24h && uptimeChecks24h > 0
|
||||
? ((uptimeChecks24h - (uptimeDown?.length || 0)) / uptimeChecks24h) * 100
|
||||
: 100;
|
||||
|
||||
// Recent scans with status breakdown
|
||||
const { data: scanStatusBreakdown } = await supabase
|
||||
.from("scans")
|
||||
.select("status")
|
||||
.gte("created_at", last24h);
|
||||
|
||||
const scansByStatus = (scanStatusBreakdown || []).reduce(
|
||||
(acc: Record<string, number>, s) => {
|
||||
const key = String(s.status || "unknown");
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
users: totalUsers || 0,
|
||||
organizations: totalOrgs || 0,
|
||||
websites: totalWebsites || 0,
|
||||
scans: {
|
||||
total: totalScans || 0,
|
||||
today: scansToday || 0,
|
||||
thisMonth: scansThisMonth || 0,
|
||||
byStatus: scansByStatus,
|
||||
},
|
||||
alerts: {
|
||||
active: activeAlerts || 0,
|
||||
},
|
||||
uptime: {
|
||||
checksLast24h: uptimeChecks24h || 0,
|
||||
overallUptime: Math.round(uptimeUpPercentage * 100) / 100,
|
||||
},
|
||||
timestamp: now.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
*
|
||||
* List all users with their organization memberships and usage stats.
|
||||
* Query params: ?page=1&limit=20&search=keyword
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const search = url.searchParams.get("search") || "";
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get users from the users table
|
||||
let query = supabase
|
||||
.from("users")
|
||||
.select("id, email, name, created_at, last_sign_in_at, organization_id", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (search) {
|
||||
query = query.or(`email.ilike.%${search}%,name.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data: users, count, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Enrich with organization info
|
||||
const enrichedUsers = await Promise.all(
|
||||
(users || []).map(async (user) => {
|
||||
const userId = user.id as string;
|
||||
// Get org memberships
|
||||
const { data: memberships } = await supabase
|
||||
.from("organization_members")
|
||||
.select("role, organizations(name, subscription_tier)")
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Get scan count
|
||||
const { count: scanCount } = await supabase
|
||||
.from("scans")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("triggered_by", "manual");
|
||||
|
||||
return {
|
||||
...user,
|
||||
memberships: memberships || [],
|
||||
totalScans: scanCount || 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
users: enrichedUsers,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
totalPages: Math.ceil((count || 0) / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users
|
||||
*
|
||||
* Update user: change role, deactivate, change subscription tier.
|
||||
* Body: { userId, action, value }
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId, action, value } = await request.json();
|
||||
|
||||
if (!userId || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId and action are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "changeRole": {
|
||||
const { organizationId, newRole } = value;
|
||||
if (!organizationId || !newRole) {
|
||||
return NextResponse.json({ error: "organizationId and newRole required" }, { status: 400 });
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("organization_members")
|
||||
.update({ role: newRole })
|
||||
.eq("user_id", userId)
|
||||
.eq("organization_id", organizationId);
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: `Role updated to ${newRole}` });
|
||||
}
|
||||
|
||||
case "changeTier": {
|
||||
const { organizationId, tier } = value;
|
||||
if (!organizationId || !tier) {
|
||||
return NextResponse.json({ error: "organizationId and tier required" }, { status: 400 });
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ subscription_tier: tier })
|
||||
.eq("id", organizationId);
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: `Subscription tier changed to ${tier}` });
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const { error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
ban_duration: "876000h", // ~100 years
|
||||
});
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: "User deactivated" });
|
||||
}
|
||||
|
||||
case "activate": {
|
||||
const { error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
ban_duration: "none",
|
||||
});
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ success: true, message: "User activated" });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users
|
||||
*
|
||||
* Delete a user and their data.
|
||||
* Body: { userId }
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove from all organizations first
|
||||
await supabase
|
||||
.from("organization_members")
|
||||
.delete()
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Delete notification preferences
|
||||
await supabase
|
||||
.from("user_notification_preferences")
|
||||
.delete()
|
||||
.eq("user_id", userId);
|
||||
|
||||
// Delete the auth user
|
||||
const { error } = await supabase.auth.admin.deleteUser(userId);
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true, message: "User deleted" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { pageId, websiteId, triggerType = "manual" } = await request.json();
|
||||
|
||||
if (!pageId && !websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Either pageId or websiteId must be provided" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let pagesToScan = [];
|
||||
|
||||
if (pageId) {
|
||||
// Scan specific page
|
||||
const { data: page, error: pageError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("id", pageId)
|
||||
.single();
|
||||
|
||||
if (pageError || !page) {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
pagesToScan = [page];
|
||||
} else {
|
||||
// Scan all active pages for website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("website_id", websiteId)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (pagesError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch pages" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
pagesToScan = pages || [];
|
||||
}
|
||||
|
||||
if (pagesToScan.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "No active pages found to scan" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create scans for all pages
|
||||
const scanPromises = pagesToScan.map(async (page) => {
|
||||
const { data: scan, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
page_id: page.id,
|
||||
status: "pending",
|
||||
trigger_type: triggerType,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to create scan for page ${page.id}:`, scanError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trigger Lighthouse scan
|
||||
try {
|
||||
await triggerLighthouseScan(scan.id as string, page.url as string);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to trigger Lighthouse scan for ${page.url}:`,
|
||||
error,
|
||||
);
|
||||
// Mark scan as failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scan.id as string);
|
||||
}
|
||||
|
||||
return scan;
|
||||
});
|
||||
|
||||
const scans = await Promise.all(scanPromises);
|
||||
const successfulScans = scans.filter((scan) => scan !== null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Started ${successfulScans.length} scans`,
|
||||
scanIds: successfulScans.map((scan) => scan.id),
|
||||
totalPages: pagesToScan.length,
|
||||
successfulScans: successfulScans.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Analysis error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to start analysis: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerLighthouseScan(scanId: string, url: string) {
|
||||
// Update scan status to running
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
try {
|
||||
// Call Lighthouse service
|
||||
const lighthouseUrl =
|
||||
process.env.LIGHTHOUSE_SERVICE_URL || "http://localhost:5001";
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Lighthouse service responded with ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Save scan results
|
||||
await saveScanResults(scanId, result);
|
||||
|
||||
// Update scan status to completed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
} catch (error) {
|
||||
console.error(`Lighthouse scan failed for ${url}:`, error);
|
||||
|
||||
// Update scan status to failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScanResults(scanId: string, lighthouseResult: any) {
|
||||
try {
|
||||
const { categories, audits, raw } = lighthouseResult;
|
||||
|
||||
// Save raw Lighthouse data
|
||||
await getSupabaseAdmin().from("scan_results").insert([
|
||||
{
|
||||
scan_id: scanId,
|
||||
raw_data: raw,
|
||||
performance_score: categories?.performance?.score
|
||||
? Math.round(categories.performance.score * 100)
|
||||
: null,
|
||||
seo_score: categories?.seo?.score
|
||||
? Math.round(categories.seo.score * 100)
|
||||
: null,
|
||||
accessibility_score: categories?.accessibility?.score
|
||||
? Math.round(categories.accessibility.score * 100)
|
||||
: null,
|
||||
best_practices_score: categories?.["best-practices"]?.score
|
||||
? Math.round(categories["best-practices"].score * 100)
|
||||
: null,
|
||||
first_contentful_paint:
|
||||
audits?.["first-contentful-paint"]?.numericValue || null,
|
||||
largest_contentful_paint:
|
||||
audits?.["largest-contentful-paint"]?.numericValue || null,
|
||||
cumulative_layout_shift:
|
||||
audits?.["cumulative-layout-shift"]?.numericValue || null,
|
||||
total_blocking_time:
|
||||
audits?.["total-blocking-time"]?.numericValue || null,
|
||||
speed_index: audits?.["speed-index"]?.numericValue || null,
|
||||
},
|
||||
]);
|
||||
|
||||
// Extract and save metric values
|
||||
const metricValues = [];
|
||||
|
||||
// Core Web Vitals
|
||||
if (audits?.["first-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "first_contentful_paint",
|
||||
value: audits["first-contentful-paint"].numericValue,
|
||||
score: audits["first-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["largest-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "largest_contentful_paint",
|
||||
value: audits["largest-contentful-paint"].numericValue,
|
||||
score: audits["largest-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["cumulative-layout-shift"]?.numericValue !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "cumulative_layout_shift",
|
||||
value: audits["cumulative-layout-shift"].numericValue,
|
||||
score: audits["cumulative-layout-shift"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["total-blocking-time"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "total_blocking_time",
|
||||
value: audits["total-blocking-time"].numericValue,
|
||||
score: audits["total-blocking-time"].score,
|
||||
});
|
||||
}
|
||||
|
||||
// Category scores
|
||||
if (categories?.performance?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "performance_score",
|
||||
value: Math.round(categories.performance.score * 100),
|
||||
score: categories.performance.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.seo?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "seo_score",
|
||||
value: Math.round(categories.seo.score * 100),
|
||||
score: categories.seo.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.accessibility?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "accessibility_score",
|
||||
value: Math.round(categories.accessibility.score * 100),
|
||||
score: categories.accessibility.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.["best-practices"]?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "best_practices_score",
|
||||
value: Math.round(categories["best-practices"].score * 100),
|
||||
score: categories["best-practices"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (metricValues.length > 0) {
|
||||
await getSupabaseAdmin().from("metric_values").insert(metricValues);
|
||||
}
|
||||
|
||||
// Save resource analysis if available
|
||||
if (raw?.audits?.["resource-summary"]?.details?.items) {
|
||||
const resources = raw.audits["resource-summary"].details.items.map(
|
||||
(item: any) => ({
|
||||
scan_id: scanId,
|
||||
resource_type: item.resourceType || "other",
|
||||
count: item.requestCount || 0,
|
||||
size_bytes: item.size || 0,
|
||||
transfer_size_bytes: item.transferSize || 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await getSupabaseAdmin().from("resource_analysis").insert(resources);
|
||||
}
|
||||
|
||||
console.log(`Successfully saved scan results for scan ${scanId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save scan results for scan ${scanId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { TIER_LIMITS } from "@/services/tierLimits";
|
||||
import { requireOrgMembership } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/billing/usage
|
||||
*
|
||||
* Returns current usage vs tier limits for an organization.
|
||||
* Requires authenticated user who is a member of the organization.
|
||||
* Query params: ?organizationId=xxx
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: "organizationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify caller belongs to this organization
|
||||
const auth = await requireOrgMembership(organizationId, request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
// Get organization with tier info
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, subscription_tier, subscription_status, created_at")
|
||||
.eq("id", organizationId)
|
||||
.single();
|
||||
|
||||
if (orgError || !org) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tier = String(org.subscription_tier || "free");
|
||||
const limits = TIER_LIMITS[tier] || TIER_LIMITS.free;
|
||||
|
||||
// Get current usage (parallel queries)
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
{ count: websiteCount },
|
||||
{ count: memberCount },
|
||||
{ count: scanCountThisMonth },
|
||||
{ count: scanCountTotal },
|
||||
{ count: alertCount },
|
||||
] = await Promise.all([
|
||||
supabase.from("websites").select("*", { count: "exact", head: true }).eq("organization_id", organizationId),
|
||||
supabase.from("organization_members").select("*", { count: "exact", head: true }).eq("organization_id", organizationId),
|
||||
supabase
|
||||
.from("scans")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", startOfMonth.toISOString()),
|
||||
supabase.from("scans").select("*", { count: "exact", head: true }),
|
||||
supabase.from("alerts").select("*", { count: "exact", head: true }).eq("status", "active"),
|
||||
]);
|
||||
|
||||
const usage = {
|
||||
websites: { used: websiteCount || 0, limit: limits.websites, percentage: limits.websites === -1 ? 0 : Math.round(((websiteCount || 0) / limits.websites) * 100) },
|
||||
scansThisMonth: { used: scanCountThisMonth || 0, limit: limits.scansPerMonth, percentage: limits.scansPerMonth === -1 ? 0 : Math.round(((scanCountThisMonth || 0) / limits.scansPerMonth) * 100) },
|
||||
teamMembers: { used: memberCount || 0, limit: limits.teamMembers, percentage: limits.teamMembers === -1 ? 0 : Math.round(((memberCount || 0) / limits.teamMembers) * 100) },
|
||||
totalScans: scanCountTotal || 0,
|
||||
activeAlerts: alertCount || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
tier,
|
||||
status: org.subscription_status || "active",
|
||||
createdAt: org.created_at,
|
||||
},
|
||||
plan: limits,
|
||||
usage,
|
||||
features: {
|
||||
scheduledScans: limits.scheduledScans,
|
||||
alertNotifications: limits.alertNotifications,
|
||||
competitorAnalysis: limits.competitorAnalysis,
|
||||
apiAccess: limits.apiAccess,
|
||||
prioritySupport: limits.prioritySupport,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
/**
|
||||
* GET /api/competitor-analysis?websiteId=xxx
|
||||
*
|
||||
* Returns your website's latest scores alongside competitor scores.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const websiteId = url.searchParams.get("websiteId");
|
||||
|
||||
if (!websiteId) {
|
||||
return NextResponse.json({ error: "websiteId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get your website's latest scan results
|
||||
const { data: website } = await supabase
|
||||
.from("websites")
|
||||
.select("id, name, base_url")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (!website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get latest scan results for your site
|
||||
const { data: yourScans } = await supabase
|
||||
.from("scan_results")
|
||||
.select("category, score, scans(website_id, created_at)")
|
||||
.eq("scans.website_id", websiteId)
|
||||
.order("created_at", { ascending: false, referencedTable: "scans" })
|
||||
.limit(4);
|
||||
|
||||
const yourScores: Record<string, number> = {};
|
||||
for (const scan of yourScans || []) {
|
||||
const category = String(scan.category || "");
|
||||
const score = Number(scan.score);
|
||||
if (category && !isNaN(score) && !yourScores[category]) {
|
||||
yourScores[category] = score;
|
||||
}
|
||||
}
|
||||
|
||||
// Get competitor entries for this website
|
||||
const { data: competitors } = await supabase
|
||||
.from("competitor_metrics")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
return NextResponse.json({
|
||||
yourSite: {
|
||||
id: website.id,
|
||||
name: website.name,
|
||||
url: website.base_url,
|
||||
scores: {
|
||||
performance: yourScores.performance ?? null,
|
||||
seo: yourScores.seo ?? null,
|
||||
accessibility: yourScores.accessibility ?? null,
|
||||
bestPractices: yourScores.best_practices ?? yourScores.bestPractices ?? null,
|
||||
},
|
||||
},
|
||||
competitors: (competitors || []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name || c.url,
|
||||
url: c.url,
|
||||
scores: {
|
||||
performance: c.performance_score,
|
||||
seo: c.seo_score,
|
||||
accessibility: c.accessibility_score,
|
||||
bestPractices: c.best_practices_score,
|
||||
},
|
||||
lastScanned: c.last_scanned_at,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Competitor analysis error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch competitor data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/competitor-analysis
|
||||
*
|
||||
* Add a competitor and scan it with Lighthouse.
|
||||
* Body: { websiteId, competitorUrl, competitorName }
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { websiteId, competitorUrl, competitorName } = await request.json();
|
||||
|
||||
if (!websiteId || !competitorUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "websiteId and competitorUrl required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(competitorUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Run a lightweight fetch-based check (no full Lighthouse to save resources)
|
||||
const start = Date.now();
|
||||
let statusCode = null;
|
||||
let responseTime = 0;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const res = await fetch(competitorUrl, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "WebsiteMonitor/1.0 (Competitor Analysis)" },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
statusCode = res.status;
|
||||
responseTime = Date.now() - start;
|
||||
} catch {
|
||||
responseTime = Date.now() - start;
|
||||
}
|
||||
|
||||
// Insert competitor record
|
||||
const { data: competitor, error } = await supabase
|
||||
.from("competitor_metrics")
|
||||
.upsert(
|
||||
{
|
||||
website_id: websiteId,
|
||||
url: competitorUrl,
|
||||
name: competitorName || new URL(competitorUrl).hostname,
|
||||
status_code: statusCode,
|
||||
response_time: responseTime,
|
||||
last_scanned_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "website_id,url" }
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
competitor,
|
||||
message: `Competitor added: ${competitorUrl} (${responseTime}ms)`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Competitor add error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to add competitor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ pages: [] });
|
||||
}
|
||||
|
||||
// Hole alle gefundenen Seiten für die Session
|
||||
const { data: pages } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("url")
|
||||
.eq("session_id", sessionId);
|
||||
|
||||
return NextResponse.json({ pages: pages?.map((p) => p.url) || [] });
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { NewCrawlerService } from "@/services/newCrawlerService";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse request body
|
||||
let websiteId;
|
||||
try {
|
||||
const body = await request.json();
|
||||
websiteId = body.websiteId;
|
||||
|
||||
// Normalize the websiteId to ensure consistent format
|
||||
websiteId = String(websiteId).trim().toLowerCase();
|
||||
console.log("Processing website ID:", websiteId);
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing request body:", parseError);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch website details using admin client (bypasses RLS)
|
||||
const { data: websites, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url")
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (websiteError) {
|
||||
console.error("Website query error:", websiteError);
|
||||
return NextResponse.json(
|
||||
{ error: `Database error: ${websiteError.message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if website exists
|
||||
if (!websites || websites.length === 0) {
|
||||
console.error("Website not found with ID:", websiteId);
|
||||
|
||||
// Try to find similar websites for debugging
|
||||
const { data: allWebsites } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url");
|
||||
|
||||
console.log(`Found ${allWebsites?.length || 0} total websites`);
|
||||
|
||||
// Log available IDs for comparison
|
||||
const availableIds =
|
||||
allWebsites?.map((w) => String(w.id).toLowerCase()) || [];
|
||||
console.log("Available website IDs:", availableIds);
|
||||
|
||||
return NextResponse.json({
|
||||
error: "Website not found",
|
||||
debug: {
|
||||
requestedId: websiteId,
|
||||
availableIds: availableIds,
|
||||
totalWebsites: allWebsites?.length || 0
|
||||
}
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Website found, proceed with crawl
|
||||
const website = websites[0];
|
||||
console.log("Found website:", website.name, website.base_url);
|
||||
|
||||
// Create a new crawl session (using admin client)
|
||||
const { data: sessions, error: sessionError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.insert([
|
||||
{
|
||||
website_id: website.id, // Use the ID from the database
|
||||
status: "pending",
|
||||
start_url: website.base_url,
|
||||
total_urls: 0,
|
||||
processed_urls: 0,
|
||||
progress_percentage: 0,
|
||||
pages_discovered: 0,
|
||||
pages_processed: 0,
|
||||
},
|
||||
])
|
||||
.select();
|
||||
|
||||
if (sessionError) {
|
||||
console.error("Failed to create crawl session:", sessionError);
|
||||
console.error("Session error details:", {
|
||||
message: sessionError.message,
|
||||
details: sessionError.details,
|
||||
hint: sessionError.hint,
|
||||
code: sessionError.code
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to create crawl session",
|
||||
details: sessionError.message,
|
||||
code: sessionError.code
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Session was created but not returned" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = sessions[0] as { id: string };
|
||||
console.log("Created crawl session:", session.id);
|
||||
|
||||
// Start crawler in background
|
||||
const crawler = new NewCrawlerService(String(website.id), String(session.id));
|
||||
crawler.startCrawl().catch((err) => {
|
||||
console.error("Crawler error:", err);
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Crawl started",
|
||||
sessionId: session.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Catch-all error handler
|
||||
console.error("Crawl initialization error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start crawl: " + (error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { scanScheduler } from "@/services/scanScheduler";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = url.searchParams.get("mode") || "all"; // "scheduled", "change_detection", "all"
|
||||
const organizationId = url.searchParams.get("organizationId"); // Optional: limit to specific org
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_start', mode, timestamp: new Date().toISOString() }));
|
||||
|
||||
const results = {
|
||||
scheduledScans: 0,
|
||||
changeDetectionScans: 0,
|
||||
errors: [] as string[],
|
||||
startTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Process scheduled scans
|
||||
if (mode === "scheduled" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_scheduled_scans', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processScheduledScans();
|
||||
|
||||
// Get count of processed scans
|
||||
const scheduledScans = await scanScheduler.getScheduledScans();
|
||||
results.scheduledScans = scheduledScans.length;
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scheduled_scans_processed', count: results.scheduledScans, timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing scheduled scans: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Process change detection
|
||||
if (mode === "change_detection" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_change_detection', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processChangeDetection();
|
||||
|
||||
// Note: Change detection count is harder to track since it's based on actual changes
|
||||
// We'll just indicate it was processed
|
||||
results.changeDetectionScans = -1; // -1 indicates processed but count unknown
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'change_detection_processed', timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing change detection: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get overall statistics
|
||||
const stats = await getScanStatistics(organizationId ?? undefined);
|
||||
|
||||
const response = {
|
||||
success: results.errors.length === 0,
|
||||
message: `Automatic scan process completed - ${results.scheduledScans} scheduled scans, change detection processed`,
|
||||
results,
|
||||
statistics: stats,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_completed', success: response.success, timestamp: new Date().toISOString() }));
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const errorMsg = `Critical error in automatic scan process: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan statistics for monitoring
|
||||
*/
|
||||
async function getScanStatistics(organizationId?: string) {
|
||||
try {
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('scans')
|
||||
.select('id, status, created_at, triggered_by');
|
||||
|
||||
if (organizationId) {
|
||||
const { data: websitesForOrg, error: orgErr } = await supabase
|
||||
.from('websites')
|
||||
.select('id')
|
||||
.eq('organization_id', organizationId);
|
||||
if (orgErr) {
|
||||
throw orgErr;
|
||||
}
|
||||
const websiteIds = (websitesForOrg || []).map((w: any) => w.id);
|
||||
if (websiteIds.length === 0) {
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
query = query.in('website_id', websiteIds as any[]);
|
||||
}
|
||||
|
||||
// Get today's scans
|
||||
const { data: todayScans } = await query
|
||||
.gte('created_at', startOfDay.toISOString());
|
||||
|
||||
// Get this month's scans
|
||||
const { data: monthScans } = await query
|
||||
.gte('created_at', startOfMonth.toISOString());
|
||||
|
||||
// Get scans by status
|
||||
const { data: statusCounts } = await query
|
||||
.select('status') as unknown as { data: Array<{ status: string }> };
|
||||
|
||||
const statusBreakdown = (statusCounts?.reduce((acc: Record<string, number>, scan: { status: string }) => {
|
||||
const key = String(scan.status || 'unknown');
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
// Get scans by trigger type
|
||||
const { data: triggerCounts } = await query
|
||||
.select('triggered_by') as unknown as { data: Array<{ triggered_by: string | null }> };
|
||||
|
||||
const triggerBreakdown = (triggerCounts?.reduce((acc: Record<string, number>, scan: { triggered_by: string | null }) => {
|
||||
const trigger = String(scan.triggered_by || 'unknown');
|
||||
acc[trigger] = (acc[trigger] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
return {
|
||||
today: {
|
||||
total: todayScans?.length || 0,
|
||||
byStatus: statusBreakdown,
|
||||
byTrigger: triggerBreakdown,
|
||||
},
|
||||
thisMonth: {
|
||||
total: monthScans?.length || 0,
|
||||
},
|
||||
last24Hours: {
|
||||
total: todayScans?.length || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error getting scan statistics', error);
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual scan trigger endpoint
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, pageId, deviceType = 'desktop', categories, priority = 'medium' } = body;
|
||||
|
||||
if (!websiteId || !pageId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and Page ID are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'manual_scan_triggered', websiteId, pageId, timestamp: new Date().toISOString() }));
|
||||
|
||||
// Check subscription limits
|
||||
const { data: website } = await (await import("@/lib/admin")).getSupabaseAdmin()
|
||||
.from('websites')
|
||||
.select('organization_id')
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (!website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { canScan, limits, currentUsage } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Subscription limit exceeded",
|
||||
limits,
|
||||
currentUsage,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: deviceType as 'desktop' | 'mobile',
|
||||
categories: categories || ['performance', 'accessibility', 'seo', 'best_practices'],
|
||||
priority: priority as 'low' | 'medium' | 'high',
|
||||
triggeredBy: 'manual' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Scan completed successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error in manual scan: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { performUptimeChecks, evaluateUptimeAlerts } from "@/services/uptimeService";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/cron/uptime
|
||||
*
|
||||
* Performs uptime checks on all active websites and evaluates alert rules.
|
||||
* Designed to be called by a cron job (e.g., GitHub Actions, Vercel Cron, or external scheduler).
|
||||
* Requires CRON_SECRET authorization in production.
|
||||
*
|
||||
* Query params:
|
||||
* - alerts=true (default) — also evaluate alert rules after checks
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const shouldEvaluateAlerts = url.searchParams.get("alerts") !== "false";
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
level: "info",
|
||||
event: "uptime_check_start",
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// Perform uptime checks
|
||||
const checkResults = await performUptimeChecks();
|
||||
|
||||
// Evaluate alert rules
|
||||
let alertsTriggered = 0;
|
||||
if (shouldEvaluateAlerts) {
|
||||
alertsTriggered = await evaluateUptimeAlerts();
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
message: `Uptime checks completed: ${checkResults.checked} websites checked`,
|
||||
results: {
|
||||
...checkResults,
|
||||
alertsTriggered,
|
||||
},
|
||||
duration: `${duration}ms`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
level: "info",
|
||||
event: "uptime_check_complete",
|
||||
...response.results,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const errorMsg = `Uptime check failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
console.error(JSON.stringify({ level: "error", event: "uptime_check_error", error: errorMsg }));
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMsg, timestamp: new Date().toISOString() },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await params;
|
||||
|
||||
// Get crawl queue items
|
||||
const { data: queueItems, error: queueError } = await getSupabaseAdmin()
|
||||
.from("crawl_queue")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (queueError) throw queueError;
|
||||
|
||||
// Get crawl sessions
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (sessionsError) throw sessionsError;
|
||||
|
||||
// Get pages discovered
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, title, is_active, depth, created_at, metadata")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (pagesError) throw pagesError;
|
||||
|
||||
// Get website info
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("*")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError) throw websiteError;
|
||||
|
||||
// Statistics
|
||||
const queueStats = {
|
||||
total: queueItems?.length || 0,
|
||||
pending: queueItems?.filter(item => item.status === 'pending').length || 0,
|
||||
processing: queueItems?.filter(item => item.status === 'processing').length || 0,
|
||||
completed: queueItems?.filter(item => item.status === 'completed').length || 0,
|
||||
failed: queueItems?.filter(item => item.status === 'failed').length || 0,
|
||||
skipped: queueItems?.filter(item => item.status === 'skipped').length || 0,
|
||||
};
|
||||
|
||||
const sessionStats = {
|
||||
total: sessions?.length || 0,
|
||||
running: sessions?.filter(s => s.status === 'running').length || 0,
|
||||
completed: sessions?.filter(s => s.status === 'completed').length || 0,
|
||||
failed: sessions?.filter(s => s.status === 'failed').length || 0,
|
||||
};
|
||||
|
||||
const pageStats = {
|
||||
total: pages?.length || 0,
|
||||
active: pages?.filter(p => p.is_active).length || 0,
|
||||
inactive: pages?.filter(p => !p.is_active).length || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
website,
|
||||
queueStats,
|
||||
sessionStats,
|
||||
pageStats,
|
||||
queueItems: queueItems?.slice(0, 20), // Last 20 queue items
|
||||
sessions: sessions?.slice(0, 5), // Last 5 sessions
|
||||
pages: pages?.slice(0, 20), // Last 20 pages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl debug info:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl debug info" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await context.params;
|
||||
|
||||
// Get crawl sessions for this website
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
// Get all pages for this website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return NextResponse.json({
|
||||
sessions,
|
||||
sessionsError,
|
||||
pages,
|
||||
pagesError,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
// In-memory rate limiter (for demo/dev only; use Redis for production)
|
||||
const rateLimit: Record<string, { count: number; last: number }> = {};
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for backend
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = req.headers.get('x-forwarded-for') || '';
|
||||
const now = Date.now();
|
||||
|
||||
// Rate limiting
|
||||
if (!rateLimit[ip]) rateLimit[ip] = { count: 0, last: now };
|
||||
if (now - rateLimit[ip].last > WINDOW_MS) {
|
||||
rateLimit[ip] = { count: 0, last: now };
|
||||
}
|
||||
rateLimit[ip].count += 1;
|
||||
if (rateLimit[ip].count > MAX_ATTEMPTS) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let email: string | undefined;
|
||||
try {
|
||||
const body = await req.json();
|
||||
email = body.email;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Debug log incoming email
|
||||
console.log('API /api/email-exists: email =', email);
|
||||
|
||||
const { data, error } = await supabase.rpc('email_exists', { email_to_check: email });
|
||||
|
||||
// Debug log Supabase response
|
||||
console.log('API /api/email-exists: supabase.rpc response =', { data, error });
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ exists: data === true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { websiteId } = await request.json();
|
||||
|
||||
// Create a new scan
|
||||
const { data: scan, error: scanError } = await supabase
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) throw scanError;
|
||||
|
||||
// Trigger the analysis process
|
||||
const response = await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
websiteId,
|
||||
scanId: scan.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to start analysis");
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, scanId: scan.id });
|
||||
} catch (error) {
|
||||
console.error("Monitor start error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start monitoring" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NotificationService } from "@/services/notificationService";
|
||||
|
||||
/**
|
||||
* POST /api/notifications/process
|
||||
*
|
||||
* Processes all pending alert notifications.
|
||||
* Sends emails via Resend and webhooks to configured URLs.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await NotificationService.processNotifications();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Notifications] Processing failed:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({ error: "Organization ID and User ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all members of the organization
|
||||
const { data: members, error: membersError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (membersError) {
|
||||
console.error("Error fetching members:", membersError);
|
||||
return NextResponse.json({ error: "Failed to fetch members" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (error) {
|
||||
console.error("Error in members GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { organizationId, email, role, invitedBy } = await request.json();
|
||||
|
||||
if (!organizationId || !email || !role || !invitedBy) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID, email, role, and inviter ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify inviter has permission (must be owner or admin)
|
||||
const { data: inviter, error: inviterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", invitedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (inviterError || !inviter) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (inviter.role !== "owner" && inviter.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can invite members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user already exists in the system
|
||||
const { data: existingUsers, error: userCheckError } = await getSupabaseAdmin()
|
||||
.auth.admin.listUsers();
|
||||
|
||||
if (userCheckError) {
|
||||
console.error("Error checking existing users:", userCheckError);
|
||||
return NextResponse.json({ error: "Failed to check existing users" }, { status: 500 });
|
||||
}
|
||||
|
||||
const existingUser = existingUsers.users.find(u => u.email === email);
|
||||
|
||||
if (existingUser) {
|
||||
// Check if user is already in an organization
|
||||
const { data: userRecord } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
if (userRecord?.organization_id) {
|
||||
if (userRecord.organization_id === organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of this organization"
|
||||
}, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of another organization"
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Add existing user to organization
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: organizationId, role })
|
||||
.eq("id", existingUser.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error adding existing user to organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to add user to organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get updated user data
|
||||
const { data: updatedUser } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
return NextResponse.json({
|
||||
member: updatedUser,
|
||||
message: "Existing user added to organization"
|
||||
});
|
||||
} else {
|
||||
// Create invitation record for new user
|
||||
// Note: In a real app, you'd send an email invitation here
|
||||
// For now, we'll just create a placeholder record
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Invitation would be sent to new user",
|
||||
action: "invitation_sent"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in members POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { memberId, role, updatedBy, organizationId } = await request.json();
|
||||
|
||||
if (!memberId || !role || !updatedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, role, updater ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify updater has permission (must be owner)
|
||||
const { data: updater, error: updaterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", updatedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (updaterError || !updater) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (updater.role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners can update member roles"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow changing the role of the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner" && role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot change the role of the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update member role
|
||||
const { data: updatedMember, error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ role })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId)
|
||||
.select("id, name, email, role, created_at")
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating member role:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update member role" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ member: updatedMember });
|
||||
} catch (error) {
|
||||
console.error("Error in members PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const memberId = url.searchParams.get("memberId");
|
||||
const removedBy = url.searchParams.get("removedBy");
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
if (!memberId || !removedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, remover ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify remover has permission (must be owner or admin)
|
||||
const { data: remover, error: removerError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", removedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (removerError || !remover) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (remover.role !== "owner" && remover.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can remove members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow removing the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot remove the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove member from organization (set organization_id to null)
|
||||
const { error: removeError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: null, role: "member" })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (removeError) {
|
||||
console.error("Error removing member:", removeError);
|
||||
return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in members DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { userId, name } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.insert([
|
||||
{
|
||||
name: name || "My Organization",
|
||||
subscription_tier: "free",
|
||||
subscription_status: "active",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
throw orgError;
|
||||
}
|
||||
|
||||
// Update user with organization ID
|
||||
const { error: userError } = await supabase
|
||||
.from("users")
|
||||
.update({ organization_id: org.id })
|
||||
.eq("id", userId);
|
||||
|
||||
if (userError) {
|
||||
throw userError;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
organization: org,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Organization creation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create organization" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get organizations where user is a member
|
||||
const { data: userOrgs, error: userOrgError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select(`
|
||||
organization_id,
|
||||
role,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
subscription_tier,
|
||||
subscription_status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq("id", userId);
|
||||
|
||||
if (userOrgError) {
|
||||
console.error("Error fetching user organizations:", userOrgError);
|
||||
return NextResponse.json({ error: "Failed to fetch organizations" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get organization stats
|
||||
const orgIds = userOrgs?.map(u => u.organization_id).filter(Boolean) || [];
|
||||
|
||||
if (orgIds.length === 0) {
|
||||
return NextResponse.json({ organizations: [] });
|
||||
}
|
||||
|
||||
const [membersData, websitesData] = await Promise.all([
|
||||
// Get member counts
|
||||
getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds),
|
||||
|
||||
// Get website counts
|
||||
getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds)
|
||||
]);
|
||||
|
||||
const memberCounts = (membersData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, member) => {
|
||||
const key = String(member.organization_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const websiteCounts = (websitesData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, website) => {
|
||||
const key = String(website.organization_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const orgsWithStats = (userOrgs as Array<any> | undefined)?.map((userOrg: any) => ({
|
||||
id: userOrg.organizations?.id || "",
|
||||
name: userOrg.organizations?.name || "",
|
||||
subscription_tier: userOrg.organizations?.subscription_tier || "free",
|
||||
subscription_status: userOrg.organizations?.subscription_status || "active",
|
||||
created_at: userOrg.organizations?.created_at || "",
|
||||
member_count: memberCounts[String(userOrg.organization_id)] || 0,
|
||||
website_count: websiteCounts[String(userOrg.organization_id)] || 0,
|
||||
user_role: userOrg.role || "member",
|
||||
})).filter((org: any) => org.id) || [];
|
||||
|
||||
return NextResponse.json({ organizations: orgsWithStats });
|
||||
} catch (error) {
|
||||
console.error("Error in organization GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, userId } = await request.json();
|
||||
|
||||
if (!id || !name || !userId) {
|
||||
return NextResponse.json({ error: "ID, name, and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to update this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can update organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update organization
|
||||
const { data: org, error: updateError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.update({ name })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ organization: org });
|
||||
} catch (error) {
|
||||
console.error("Error in organization PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({ error: "ID and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can delete organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete organization (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting organization:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in organization DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url, deviceType = 'desktop' } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Test the Lighthouse scanner directly
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lighthouse service responded with ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Lighthouse test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Lighthouse test failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test the scanner worker health
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scanner worker health check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const health = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scannerWorker: health,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Health check failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test basic database connection
|
||||
const { data: websites, error: websitesError } = await supabase
|
||||
.from('websites')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test scans table connection
|
||||
const { data: scans, error: scansError } = await supabase
|
||||
.from('scans')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test users table connection
|
||||
const { data: users, error: usersError } = await supabase
|
||||
.from('users')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
database: {
|
||||
websites: {
|
||||
connected: !websitesError,
|
||||
error: websitesError?.message || null
|
||||
},
|
||||
scans: {
|
||||
connected: !scansError,
|
||||
error: scansError?.message || null
|
||||
},
|
||||
users: {
|
||||
connected: !usersError,
|
||||
error: usersError?.message || null
|
||||
}
|
||||
},
|
||||
message: "Database connection test completed"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database test error:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: "Database connection test failed"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
// Check auth state in API route
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
console.log("API route auth check:", {
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
authError: authError?.message,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id || null,
|
||||
authError: authError?.message || null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let validUrl: URL;
|
||||
try {
|
||||
validUrl = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(validUrl.protocol)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only HTTP and HTTPS URLs are supported" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the website with a timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'CloudLense Website Validator/1.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: `Website returned ${response.status} ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get content type
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.includes('text/html')) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'URL does not point to an HTML page',
|
||||
});
|
||||
}
|
||||
|
||||
// Parse HTML to extract metadata
|
||||
const html = await response.text();
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// Extract meta description
|
||||
const descriptionMatch = html.match(/<meta[^>]+name=['"]description['"][^>]+content=['"]([^'"]*)['"]/i);
|
||||
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
|
||||
|
||||
// Try to get favicon
|
||||
const faviconMatch = html.match(/<link[^>]+rel=['"](?:icon|shortcut icon)['"][^>]+href=['"]([^'"]*)['"]/i);
|
||||
let favicon = faviconMatch ? faviconMatch[1] : '/favicon.ico';
|
||||
|
||||
// Convert relative favicon URL to absolute
|
||||
if (favicon && !favicon.startsWith('http')) {
|
||||
favicon = new URL(favicon, url).href;
|
||||
}
|
||||
|
||||
// Validate favicon exists
|
||||
let validFavicon: string | undefined;
|
||||
try {
|
||||
const faviconResponse = await fetch(favicon, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (faviconResponse.ok) {
|
||||
validFavicon = favicon;
|
||||
}
|
||||
} catch {
|
||||
// If favicon fails, try the Google favicon service
|
||||
validFavicon = `https://www.google.com/s2/favicons?domain=${validUrl.hostname}&sz=32`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: true,
|
||||
title: title || validUrl.hostname,
|
||||
description: description || '',
|
||||
favicon: validFavicon,
|
||||
hostname: validUrl.hostname,
|
||||
protocol: validUrl.protocol,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Website took too long to respond (timeout)',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Unable to connect to website. Please check the URL and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Website validation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, url, changeType, contentHash, metadata } = body;
|
||||
|
||||
if (!websiteId || !url) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and URL are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Structured log for production visibility
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'webhook_website_change_received',
|
||||
websiteId,
|
||||
changeType: changeType || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Verify the webhook signature if needed
|
||||
const signature = request.headers.get('x-webhook-signature');
|
||||
if (process.env.WEBHOOK_SECRET && signature) {
|
||||
// Add webhook signature verification here if needed
|
||||
// const isValid = verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET);
|
||||
// if (!isValid) {
|
||||
// return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
// }
|
||||
}
|
||||
|
||||
// Get website details
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const { data: website, error: websiteError } = await supabase
|
||||
.from('websites')
|
||||
.select(`
|
||||
id,
|
||||
organization_id,
|
||||
organizations!inner (
|
||||
subscription_tier
|
||||
)
|
||||
`)
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check subscription limits
|
||||
const { canScan, limits } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'subscription_limit_exceeded',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Subscription limit exceeded - scan skipped",
|
||||
limits,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if change detection is enabled for this subscription
|
||||
const subscriptionLimits = getSubscriptionLimits(String((website as any).organizations?.subscription_tier || 'free'));
|
||||
if (!subscriptionLimits.changeDetectionEnabled) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'change_detection_disabled',
|
||||
tier: String((website as any).organizations?.subscription_tier || 'unknown'),
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Change detection not available for this subscription tier",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create the page record
|
||||
let pageId: string;
|
||||
const { data: existingPage } = await supabase
|
||||
.from('pages')
|
||||
.select('id, content_hash')
|
||||
.eq('website_id', websiteId)
|
||||
.eq('url', url)
|
||||
.single();
|
||||
|
||||
if (existingPage) {
|
||||
pageId = String((existingPage as any).id);
|
||||
|
||||
// Update the page with new content hash
|
||||
await supabase
|
||||
.from('pages')
|
||||
.update({
|
||||
content_hash: contentHash,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.eq('id', pageId);
|
||||
} else {
|
||||
// Create new page record
|
||||
const { data: newPage, error: createError } = await supabase
|
||||
.from('pages')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
url,
|
||||
path: new URL(url).pathname,
|
||||
content_hash: contentHash,
|
||||
title: metadata?.title || 'Unknown Page',
|
||||
is_active: true,
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (createError || !newPage) {
|
||||
throw new Error(`Failed to create page record: ${createError?.message}`);
|
||||
}
|
||||
|
||||
pageId = String((newPage as any).id);
|
||||
}
|
||||
|
||||
// Trigger a high-priority scan due to changes
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: 'desktop' as const,
|
||||
categories: ['performance', 'accessibility', 'seo', 'best_practices'] as (
|
||||
'performance' | 'accessibility' | 'seo' | 'best_practices'
|
||||
)[],
|
||||
priority: 'high' as const,
|
||||
triggeredBy: 'change_detection' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
// Log the change detection
|
||||
await supabase
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
action: 'change_detected',
|
||||
entity_type: 'page',
|
||||
entity_id: pageId,
|
||||
changes: {
|
||||
change_type: changeType || 'content_update',
|
||||
url,
|
||||
content_hash: contentHash,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'change_detection_scan_completed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Change detection scan triggered successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
console.error(JSON.stringify({
|
||||
level: 'error',
|
||||
event: 'change_detection_scan_failed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing website change webhook: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription limits based on tier
|
||||
*/
|
||||
function getSubscriptionLimits(tier: string) {
|
||||
switch (tier) {
|
||||
case 'free':
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
case 'starter':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'professional':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'enterprise':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has access to this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get scan configurations
|
||||
const { data: scanConfigs, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
// Get alert configurations
|
||||
const { data: alertConfigs, error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
if (scanError || alertError) {
|
||||
console.error("Error fetching configurations:", { scanError, alertError });
|
||||
}
|
||||
|
||||
// Structure the response
|
||||
const crawl = (website as any).crawl_settings || {};
|
||||
const settings = {
|
||||
scan: {
|
||||
scanInterval: crawl.crawl_frequency === "hourly" ? 60 :
|
||||
crawl.crawl_frequency === "daily" ? 1440 : 60,
|
||||
maxPages: crawl.max_pages || 100,
|
||||
maxDepth: crawl.max_depth || 3,
|
||||
userAgent: crawl.user_agent || "",
|
||||
excludePatterns: crawl.exclude_patterns || ["/admin/*", "/api/*"],
|
||||
includePatterns: crawl.include_patterns || ["/*"],
|
||||
respectRobotsTxt: crawl.respect_robots_txt !== false,
|
||||
followRedirects: crawl.follow_redirects !== false,
|
||||
maxConcurrentRequests: crawl.max_concurrent_requests || 3,
|
||||
},
|
||||
alerts: {
|
||||
performanceThreshold: 90,
|
||||
seoThreshold: 90,
|
||||
accessibilityThreshold: 90,
|
||||
uptimeThreshold: 99,
|
||||
notificationEmail: "",
|
||||
slackWebhook: "",
|
||||
enableEmailAlerts: true,
|
||||
enableSlackAlerts: false,
|
||||
alertFrequency: "immediate" as const,
|
||||
},
|
||||
scanConfigurations: scanConfigs || [],
|
||||
alertConfigurations: alertConfigs || [],
|
||||
};
|
||||
|
||||
// Override with actual alert settings if they exist
|
||||
if (alertConfigs && alertConfigs.length > 0) {
|
||||
alertConfigs.forEach((config: any) => {
|
||||
switch (config.metric) {
|
||||
case "performance":
|
||||
settings.alerts.performanceThreshold = Number(config.threshold) || settings.alerts.performanceThreshold;
|
||||
break;
|
||||
case "seo":
|
||||
settings.alerts.seoThreshold = Number(config.threshold) || settings.alerts.seoThreshold;
|
||||
break;
|
||||
case "accessibility":
|
||||
settings.alerts.accessibilityThreshold = Number(config.threshold) || settings.alerts.accessibilityThreshold;
|
||||
break;
|
||||
case "uptime":
|
||||
settings.alerts.uptimeThreshold = Number(config.threshold) || settings.alerts.uptimeThreshold;
|
||||
break;
|
||||
}
|
||||
settings.alerts.enableEmailAlerts = config.notification_channels?.includes("email") || false;
|
||||
settings.alerts.enableSlackAlerts = config.notification_channels?.includes("slack") || false;
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
console.error("Error in website settings GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, settingsType, settings } = await request.json();
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId || !settingsType) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID, user ID, and settings type are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (settingsType === "scan") {
|
||||
// Update crawl settings
|
||||
const updatedCrawlSettings = {
|
||||
...(website as any).crawl_settings || {},
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
user_agent: settings.userAgent,
|
||||
exclude_patterns: settings.excludePatterns,
|
||||
include_patterns: settings.includePatterns,
|
||||
respect_robots_txt: settings.respectRobotsTxt,
|
||||
follow_redirects: settings.followRedirects,
|
||||
max_concurrent_requests: settings.maxConcurrentRequests,
|
||||
crawl_frequency: settings.scanInterval === 60 ? "hourly" :
|
||||
settings.scanInterval === 1440 ? "daily" : "hourly",
|
||||
};
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ crawl_settings: updatedCrawlSettings })
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating scan settings:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update scan settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Update or create scan configurations
|
||||
const { error: scanConfigError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.upsert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
category: "performance",
|
||||
interval_minutes: settings.scanInterval,
|
||||
is_active: true,
|
||||
priority: 1,
|
||||
settings: {
|
||||
lighthouse: true,
|
||||
uptime: true,
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (scanConfigError) {
|
||||
console.error("Error updating scan configuration:", scanConfigError);
|
||||
}
|
||||
|
||||
} else if (settingsType === "alerts") {
|
||||
// Update alert configurations
|
||||
const alertTypes = [
|
||||
{ metric: "performance", threshold: settings.performanceThreshold },
|
||||
{ metric: "seo", threshold: settings.seoThreshold },
|
||||
{ metric: "accessibility", threshold: settings.accessibilityThreshold },
|
||||
{ metric: "uptime", threshold: settings.uptimeThreshold },
|
||||
];
|
||||
|
||||
const notificationChannels: string[] = [];
|
||||
if (settings.enableEmailAlerts) notificationChannels.push("email");
|
||||
if (settings.enableSlackAlerts) notificationChannels.push("slack");
|
||||
|
||||
const alertConfigs = alertTypes.map(alert => ({
|
||||
website_id: websiteId,
|
||||
metric: alert.metric,
|
||||
threshold: alert.threshold,
|
||||
comparison: "less_than",
|
||||
notification_channels: notificationChannels,
|
||||
is_active: true,
|
||||
alert_frequency: settings.alertFrequency,
|
||||
email_address: settings.notificationEmail || null,
|
||||
slack_webhook: settings.slackWebhook || null,
|
||||
}));
|
||||
|
||||
// Delete existing configurations and insert new ones
|
||||
await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.delete()
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
const { error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.insert(alertConfigs);
|
||||
|
||||
if (alertError) {
|
||||
console.error("Error updating alert configurations:", alertError);
|
||||
return NextResponse.json({ error: "Failed to update alert settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${settingsType} settings updated successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in website settings PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { action, websiteIds, userId, updates } = await request.json();
|
||||
|
||||
if (!action || !websiteIds || !Array.isArray(websiteIds) || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Action, website IDs array, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to all websites
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, organization_id, name")
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (websitesError || !websites) {
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Check if all websites belong to user's organization
|
||||
const { data: userOrg, error: userError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (userError || !userOrg) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const unauthorizedWebsites = websites.filter(w => w.organization_id !== userOrg.organization_id);
|
||||
if (unauthorizedWebsites.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "Access denied to some websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
|
||||
switch (action) {
|
||||
case "activate":
|
||||
const { error: activateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: true })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (activateError) {
|
||||
return NextResponse.json({ error: "Failed to activate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "activated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "deactivate":
|
||||
const { error: deactivateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: false })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deactivateError) {
|
||||
return NextResponse.json({ error: "Failed to deactivate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deactivated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
// Only owners and admins can delete websites
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: "Failed to delete websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deleted"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "update":
|
||||
if (!updates || typeof updates !== "object") {
|
||||
return NextResponse.json({
|
||||
error: "Updates object is required for update action"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.crawl_settings) updateData.crawl_settings = updates.crawl_settings;
|
||||
if (updates.is_active !== undefined) updateData.is_active = updates.is_active;
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: "Failed to update websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "updated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "scan":
|
||||
// Trigger scans for all websites
|
||||
const scanPromises = websiteIds.map(async (websiteId: string) => {
|
||||
try {
|
||||
// Insert scan request
|
||||
const { error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
triggered_by: userId,
|
||||
scan_type: "manual",
|
||||
}
|
||||
]);
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to trigger scan for website ${websiteId}:`, scanError);
|
||||
return { id: websiteId, status: "scan_failed", error: scanError.message };
|
||||
}
|
||||
|
||||
return { id: websiteId, status: "scan_triggered" };
|
||||
} catch (error) {
|
||||
console.error(`Error triggering scan for website ${websiteId}:`, error);
|
||||
return { id: websiteId, status: "scan_failed" };
|
||||
}
|
||||
});
|
||||
|
||||
const scanResults = await Promise.all(scanPromises);
|
||||
results = websites.map(w => {
|
||||
const scanResult = scanResults.find(r => r.id === w.id);
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: scanResult?.status || "scan_failed",
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({
|
||||
error: "Invalid action. Supported actions: activate, deactivate, delete, update, scan"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action,
|
||||
results,
|
||||
message: `Successfully ${action}d ${results.length} website(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in websites bulk operation:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID and User ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get websites with their latest scan information
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
is_active,
|
||||
created_at,
|
||||
settings
|
||||
`)
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (websitesError) {
|
||||
console.error("Error fetching websites:", websitesError);
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get additional stats for each website
|
||||
const websiteIds = websites?.map(w => w.id) || [];
|
||||
|
||||
if (websiteIds.length > 0) {
|
||||
const [pagesData, scansData] = await Promise.all([
|
||||
getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("website_id")
|
||||
.in("website_id", websiteIds),
|
||||
|
||||
getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.select("website_id, status, lighthouse_score")
|
||||
.in("website_id", websiteIds)
|
||||
.order("created_at", { ascending: false })
|
||||
]);
|
||||
|
||||
// Count pages per website
|
||||
const pagesCounts = (pagesData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, page) => {
|
||||
const key = String(page.website_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
// Get latest scan per website
|
||||
const latestScans = (scansData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, any>, scan: any) => {
|
||||
const key = String(scan.website_id);
|
||||
if (!acc[key]) {
|
||||
acc[key] = scan;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>) || {};
|
||||
|
||||
// Add stats to websites
|
||||
const websitesWithStats = (websites as Array<any>).map((website: any) => ({
|
||||
...website,
|
||||
stats: {
|
||||
pagesCount: pagesCounts[String(website.id)] || 0,
|
||||
latestScan: latestScans[String(website.id)] || null,
|
||||
}
|
||||
}));
|
||||
|
||||
return NextResponse.json({ websites: websitesWithStats });
|
||||
}
|
||||
|
||||
return NextResponse.json({ websites: websites || [] });
|
||||
} catch (error) {
|
||||
console.error("Error in websites GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, base_url, organizationId, userId, crawl_settings } = await request.json();
|
||||
|
||||
if (!name || !base_url || !organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Name, URL, organization ID, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has permission to add websites to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
normalizedUrl = normalizedUrl.replace(/\/+$/, "");
|
||||
|
||||
// Default crawl settings
|
||||
const defaultCrawlSettings = {
|
||||
max_depth: 3,
|
||||
max_pages: 100,
|
||||
exclude_patterns: ["/admin/*", "/api/*", "*.pdf", "*.jpg", "*.png"],
|
||||
include_patterns: ["/*"],
|
||||
respect_robots_txt: true,
|
||||
crawl_frequency: "daily",
|
||||
...crawl_settings
|
||||
};
|
||||
|
||||
// Create website
|
||||
const { data: website, error: createError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.insert([
|
||||
{
|
||||
name,
|
||||
base_url: normalizedUrl,
|
||||
organization_id: organizationId,
|
||||
is_active: true,
|
||||
crawl_settings: defaultCrawlSettings,
|
||||
scan_status: "pending"
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
console.error("Error creating website:", createError);
|
||||
return NextResponse.json({ error: "Failed to create website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website });
|
||||
} catch (error) {
|
||||
console.error("Error in websites POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, base_url, is_active, crawl_settings, userId } = await request.json();
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: any = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (base_url !== undefined) {
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
updateData.base_url = normalizedUrl.replace(/\/+$/, "");
|
||||
}
|
||||
if (is_active !== undefined) updateData.is_active = is_active;
|
||||
if (crawl_settings !== undefined) updateData.crawl_settings = crawl_settings;
|
||||
|
||||
// Update website
|
||||
const { data: updatedWebsite, error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating website:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website: updatedWebsite });
|
||||
} catch (error) {
|
||||
console.error("Error in websites PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, name")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to delete this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete website (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting website:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Website "${website.name}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in websites DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user