diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 1cec9be..d741ca0 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,15 +1,38 @@ name-template: "v$NEXT_PATCH_VERSION" tag-template: "v$NEXT_PATCH_VERSION" + +version-resolver: + major: + labels: + - "major" + - "breaking" + minor: + labels: + - "minor" + - "feature" + - "enhancement" + patch: + labels: + - "patch" + - "bug" + default: patch + categories: - title: "🚀 Features" labels: - feature - enhancement - - title: "🐛 Fixes" + - feat + - title: "🐛 Bug Fixes" labels: - bug - fix - bugfix + - title: "🛡️ Security" + labels: + - security + - CVE + - CWE - title: "🛠 Maintenance" labels: - chore @@ -23,6 +46,37 @@ categories: labels: - test - tests + +autolabeler: + - label: "feature" + branch: + - "/feature/.+/" + title: + - "/^feat:.+/" + - label: "bug" + branch: + - "/bug/.+/" + - "/fix/.+/" + title: + - "/^fix:.+/" + - label: "security" + title: + - "/CVE-.+/" + - "/CWE-.+/" + - "/security/i" + - label: "chore" + title: + - "/^chore:.+/" + - label: "documentation" + title: + - "/^docs:.+/" + +replacers: + - search: "/CVE-(\\d{4}-\\d+)/g" + replace: "[CVE-$1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-$1)" + - search: "/CWE-(\\d+)/g" + replace: "[CWE-$1](https://cwe.mitre.org/node/index.html?608?id=$1)" + change-template: "- $TITLE (#$NUMBER) @$AUTHOR" no-changes-template: "No changes in this release." template: | diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index b6fc52d..065dd1e 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,9 +1,27 @@ { - "extends": ["next/core-web-vitals"], + "extends": [ + "next/core-web-vitals" + ], + "plugins": [ + "@typescript-eslint" + ], "rules": { - "no-unused-vars": "warn", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "no-console": "warn", "prefer-const": "error" }, - "ignorePatterns": ["node_modules", ".next", "dist", "build"] + "ignorePatterns": [ + "node_modules", + ".next", + "dist", + "build" + ] } \ No newline at end of file diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index 37e7453..6f26025 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -31,6 +31,7 @@ import { import { FaMicrosoft } from "react-icons/fa" import { buildDiscordLoginUrl } from "@/lib/config" import { RoleBadge } from "@/components/role-badge" +import { logError } from "@/lib/utils/error-handling" const PREFERENCE_DEFAULTS = { preferredLanguage: "en", @@ -368,7 +369,7 @@ const [subscriptionPreview, setSubscriptionPreview] = useState ({ ...prev, handle: "" })) } catch (error) { - console.error("Failed to add linked account:", error) + logError("Failed to add linked account", error) setLinkedAccountsError(error instanceof Error ? error.message : "Unable to add linked account") } finally { setLinkedAccountSaving(false) @@ -604,7 +605,7 @@ const profileShareUrl = profileShareSlug const data = await response.json() setLinkedAccounts(Array.isArray(data.accounts) ? data.accounts : []) } catch (error) { - console.error("Failed to remove linked account:", error) + logError("Failed to remove linked account", error) setLinkedAccountsError(error instanceof Error ? error.message : "Unable to remove linked account") } finally { setLinkedAccountSaving(false) @@ -651,7 +652,7 @@ const profileShareUrl = profileShareSlug })), ) } catch (error) { - console.error("Failed to load subscriptions:", error) + logError("Failed to load subscriptions", error) setSubscriptions([]) setSubscriptionsError("Unable to load subscriptions right now.") } finally { @@ -681,11 +682,11 @@ const profileShareUrl = profileShareSlug try { window.localStorage.setItem("preferred_language", data.preferredLanguage) } catch (error) { - console.error("Failed to persist language preference locally:", error) + logError("Failed to persist language preference locally", error) } } } catch (error) { - console.error("Failed to load preferences:", error) + logError("Failed to load preferences", error) } }, []) @@ -712,11 +713,11 @@ const profileShareUrl = profileShareSlug try { window.localStorage.setItem("preferred_language", value) } catch (error) { - console.error("Failed to persist language preference locally:", error) + logError("Failed to persist language preference locally", error) } } } catch (error) { - console.error("Failed to update language preference:", error) + logError("Failed to update language preference", error) setLanguageError("Failed to update language") } finally { setLanguageSaving(false) @@ -752,7 +753,7 @@ const profileShareUrl = profileShareSlug } setBillingMessage("Billing details saved.") } catch (error) { - console.error("Failed to save billing details:", error) + logError("Failed to save billing details", error) setBillingError("Could not save billing details.") } finally { setBillingSaving(false) @@ -773,7 +774,7 @@ const profileShareUrl = profileShareSlug ...data, }) } catch (error) { - console.error("Failed to load notifications:", error) + logError("Failed to load notifications", error) setNotificationsError("Unable to load notifications") } finally { setNotificationsLoading(false) @@ -800,7 +801,7 @@ const profileShareUrl = profileShareSlug throw new Error(payload.error || "Failed to update notifications") } } catch (error) { - console.error("Failed to update notifications:", error) + logError("Failed to update notifications", error) setNotificationsError("Failed to save notification settings") } finally { setNotificationsSaving(false) @@ -823,7 +824,7 @@ const profileShareUrl = profileShareSlug ...data, }) } catch (error) { - console.error("Failed to load privacy settings:", error) + logError("Failed to load privacy settings", error) setPrivacyError("Unable to load privacy settings") } finally { setPrivacyLoading(false) @@ -850,7 +851,7 @@ const profileShareUrl = profileShareSlug throw new Error(payload.error || "Failed to update privacy settings") } } catch (error) { - console.error("Failed to update privacy settings:", error) + logError("Failed to update privacy settings", error) setPrivacyError("Failed to save privacy settings") } finally { setPrivacySaving(false) @@ -873,7 +874,7 @@ const profileShareUrl = profileShareSlug ...data, }) } catch (error) { - console.error("Failed to load security settings:", error) + logError("Failed to load security settings", error) setSecurityError("Unable to load security settings") } finally { setSecurityLoading(false) @@ -908,7 +909,7 @@ const profileShareUrl = profileShareSlug return fetched[0]?.id ?? prev }) } catch (error) { - console.error("Failed to load sessions:", error) + logError("Failed to load sessions", error) setSessionsError("Unable to load active sessions") } finally { setSessionsLoading(false) @@ -934,7 +935,7 @@ const profileShareUrl = profileShareSlug setBackupCodes(Array.isArray(data.codes) ? data.codes : []) setBackupCodesFetched(true) } catch (error) { - console.error("Failed to load backup codes:", error) + logError("Failed to load backup codes", error) setBackupCodesError("Unable to fetch backup codes") } finally { setBackupCodesLoading(false) @@ -980,7 +981,7 @@ const profileShareUrl = profileShareSlug throw new Error(payload.error || "Failed to update security settings") } } catch (error) { - console.error("Failed to update security settings:", error) + logError("Failed to update security settings", error) setSecurityError("Failed to save security settings") } finally { setSecuritySaving(false) @@ -1016,7 +1017,7 @@ const profileShareUrl = profileShareSlug anchor.click() window.URL.revokeObjectURL(url) } catch (error) { - console.error("Failed to download export:", error) + logError("Failed to download export", error) setPrivacyError("Unable to download your data export") } finally { setDownloadingData(false) @@ -1070,7 +1071,7 @@ const profileShareUrl = profileShareSlug setBackupCodesVisible(true) fetchSecurity(formData.discordId) } catch (error) { - console.error("Failed to regenerate backup codes:", error) + logError("Failed to regenerate backup codes", error) setBackupCodesError(error instanceof Error ? error.message : "Failed to regenerate backup codes") } finally { setBackupCodesLoading(false) @@ -1174,7 +1175,7 @@ const profileShareUrl = profileShareSlug void fetchSubscriptions(resolvedUserId) void fetchProfileSettings(resolvedUserId) } catch (error) { - console.error("Auth check failed:", error) + logError("Auth check failed", error) localStorage.removeItem("discord_token") localStorage.removeItem("discord_user_id") if (!cancelled) { @@ -1248,7 +1249,7 @@ const profileShareUrl = profileShareSlug } fetchSessions(formData.discordId, authToken) } catch (error) { - console.error("Failed to revoke session:", error) + logError("Failed to revoke session", error) setSessionsError(error instanceof Error ? error.message : "Unable to revoke session") } }, diff --git a/frontend/app/api/account/bot-settings/route.ts b/frontend/app/api/account/bot-settings/route.ts index 6f94f5d..7a59a92 100644 --- a/frontend/app/api/account/bot-settings/route.ts +++ b/frontend/app/api/account/bot-settings/route.ts @@ -5,6 +5,7 @@ import { emitBotDefaultsUpdate } from "@/lib/server-settings-sync" import { authorizeRequest } from "@/lib/api-auth" import { clamp } from "@/lib/math" import { getApiKeySecrets } from "@/lib/api-keys" +import { logError } from "@/lib/utils/error-handling" type RouteDeps = { verifyUser?: typeof verifyRequestForUser @@ -65,7 +66,7 @@ export const createBotSettingsHandlers = (deps: RouteDeps = {}) => { void emitBotDefaultsUpdate(discordId, settings as unknown as Record) return NextResponse.json(settings) } catch (error) { - console.error("[VectoBeat] Failed to update bot settings:", error) + logError("Failed to update bot settings", error) return NextResponse.json({ error: "Failed to update bot settings" }, { status: 500 }) } } diff --git a/frontend/app/api/account/contact/route.ts b/frontend/app/api/account/contact/route.ts index c392b2e..2bcda1e 100644 --- a/frontend/app/api/account/contact/route.ts +++ b/frontend/app/api/account/contact/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server" import { verifyRequestForUser } from "@/lib/auth" import { getUserContact, upsertUserContact } from "@/lib/db" +import { logError } from "@/lib/utils/error-handling" type RouteDeps = { verifyUser?: typeof verifyRequestForUser @@ -48,7 +49,7 @@ export const createContactHandlers = (deps: RouteDeps = {}) => { const updated = await fetchContact(discordId) return NextResponse.json(updated) } catch (error) { - console.error("[VectoBeat] Failed to update contact:", error) + logError("Failed to update contact", error) return NextResponse.json({ error: "Failed to update contact info" }, { status: 500 }) } } diff --git a/frontend/app/api/account/export/route.ts b/frontend/app/api/account/export/route.ts index 06dcf58..aa60ae1 100644 --- a/frontend/app/api/account/export/route.ts +++ b/frontend/app/api/account/export/route.ts @@ -5,6 +5,7 @@ import { getFullUserData, getStoredUserProfile, getPool } from "@/lib/db" import { ensureStripeCustomerForUser } from "@/lib/stripe-customers" import { verifyRequestForUser } from "@/lib/auth" import { PdfGenerator } from "@/lib/pdf-generator" +import { logError } from "@/lib/utils/error-handling" // Helper to format dates for the report const formatDate = (date: Date | string | null | undefined) => { @@ -97,7 +98,7 @@ export async function GET(request: NextRequest) { stripeId = resolvedId } } catch (err) { - console.error("Failed to resolve Stripe ID during export:", err) + logError("Failed to resolve Stripe ID during export", err) } } @@ -314,7 +315,7 @@ export async function GET(request: NextRequest) { } }) } catch (err) { - console.error("[VectoBeat] Failed to log compliance data export request:", err) + logError("Failed to log compliance data export request", err) } } diff --git a/frontend/app/api/account/linked-accounts/route.ts b/frontend/app/api/account/linked-accounts/route.ts index 398eea5..d279a66 100644 --- a/frontend/app/api/account/linked-accounts/route.ts +++ b/frontend/app/api/account/linked-accounts/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server" import { verifyRequestForUser } from "@/lib/auth" import { addLinkedAccount, getLinkedAccounts, removeLinkedAccount } from "@/lib/db" +import { logError } from "@/lib/utils/error-handling" type RouteDeps = { verifyUser?: typeof verifyRequestForUser @@ -28,7 +29,7 @@ export const createLinkedAccountHandlers = (deps: RouteDeps = {}) => { const accounts = await fetchLinkedAccounts(discordId) return NextResponse.json({ accounts }) } catch (error) { - console.error("[VectoBeat] Linked accounts GET failed:", error) + logError("Linked accounts GET failed", error) return NextResponse.json({ error: "Unable to load linked accounts" }, { status: 500 }) } } @@ -47,7 +48,7 @@ export const createLinkedAccountHandlers = (deps: RouteDeps = {}) => { const accounts = await fetchLinkedAccounts(discordId) return NextResponse.json({ accounts }) } catch (error) { - console.error("[VectoBeat] Linked accounts POST failed:", error) + logError("Linked accounts POST failed", error) return NextResponse.json({ error: "Unable to add linked account" }, { status: 500 }) } } @@ -66,7 +67,7 @@ export const createLinkedAccountHandlers = (deps: RouteDeps = {}) => { const accounts = await fetchLinkedAccounts(discordId) return NextResponse.json({ accounts }) } catch (error) { - console.error("[VectoBeat] Linked accounts DELETE failed:", error) + logError("Linked accounts DELETE failed", error) return NextResponse.json({ error: "Unable to remove linked account" }, { status: 500 }) } } diff --git a/frontend/app/api/account/notifications/route.ts b/frontend/app/api/account/notifications/route.ts index e81c8ab..d335b3b 100644 --- a/frontend/app/api/account/notifications/route.ts +++ b/frontend/app/api/account/notifications/route.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from "next/server" import { verifyRequestForUser } from "@/lib/auth" import { getUserContact, getUserNotifications, updateUserNotifications } from "@/lib/db" import { sendNotificationEmail } from "@/lib/mailer" +import { logError } from "@/lib/utils/error-handling" const appUrl = process.env.NEXT_PUBLIC_URL || @@ -74,8 +75,7 @@ export const createNotificationHandlers = (deps: RouteDeps = {}) => { ([key, value]) => ` ${key.replace(/([A-Z])/g, " $1")} - ${ - value ? "Enabled" : "Disabled" + ${value ? "Enabled" : "Disabled" } `, ) @@ -116,7 +116,7 @@ export const createNotificationHandlers = (deps: RouteDeps = {}) => { return NextResponse.json(notifications) } catch (error) { - console.error("[VectoBeat] Failed to update notifications:", error) + logError("Failed to update notifications", error) return NextResponse.json({ error: "Failed to update notifications" }, { status: 500 }) } } diff --git a/frontend/app/api/auth/discord/callback/route.ts b/frontend/app/api/auth/discord/callback/route.ts index 457f4a2..9789b8f 100644 --- a/frontend/app/api/auth/discord/callback/route.ts +++ b/frontend/app/api/auth/discord/callback/route.ts @@ -208,18 +208,18 @@ export async function GET(request: NextRequest) { // Persist user profile and guilds const mappedGuilds = Array.isArray(guilds) ? guilds.map((g: any) => { - const perms = typeof g.permissions === "string" ? Number(g.permissions) : 0 - const isAdmin = Boolean(g.owner) || (Number.isFinite(perms) && ((perms & 0x20) !== 0 || (perms & 0x8) !== 0)) - return { - id: g.id, - name: g.name, - icon: g.icon, - owner: g.owner, - permissions: g.permissions, - isAdmin, - hasBot: false, // Assume false initially; actual presence checked via bot status/subscriptions - } - }) + const perms = typeof g.permissions === "string" ? Number(g.permissions) : 0 + const isAdmin = Boolean(g.owner) || (Number.isFinite(perms) && ((perms & 0x20) !== 0 || (perms & 0x8) !== 0)) + return { + id: g.id, + name: g.name, + icon: g.icon, + owner: g.owner, + permissions: g.permissions, + isAdmin, + hasBot: false, // Assume false initially; actual presence checked via bot status/subscriptions + } + }) : [] await persistUserProfile({ @@ -231,7 +231,7 @@ export async function GET(request: NextRequest) { phone: userData.phone || null, avatar: userData.avatar, avatarUrl: userData.avatar - ? `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.png` + ? `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.${userData.avatar.startsWith("a_") ? "gif" : "png"}` : null, guilds: mappedGuilds, }) diff --git a/frontend/app/api/blog/[id]/route.ts b/frontend/app/api/blog/[id]/route.ts index cdaaa48..cc37519 100644 --- a/frontend/app/api/blog/[id]/route.ts +++ b/frontend/app/api/blog/[id]/route.ts @@ -1,5 +1,8 @@ import { type NextRequest, NextResponse } from "next/server" import { + getUserRole, + saveBlogPost, + deleteBlogPost, getBlogPostByIdentifier, getBlogPosts, getBlogReactions, @@ -8,6 +11,7 @@ import { type BlogReactionSummary, type BlogComment, } from "@/lib/db" +import { verifyRequestForUser } from "@/lib/auth" type RouteParams = { params: Promise<{ id: string }> } @@ -53,3 +57,74 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { comments, }) } +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const identifier = sanitizeIdentifier(id) + if (!identifier) { + return NextResponse.json({ error: "missing identifier" }, { status: 400 }) + } + + const { discordId } = await request.json().catch(() => ({})) + if (!discordId) { + return NextResponse.json({ error: "discordId is required" }, { status: 400 }) + } + + const auth = await verifyRequestForUser(request, discordId) + if (!auth.valid) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const role = await getUserRole(discordId) + if (role !== "admin" && role !== "operator") { + return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }) + } + + const success = await deleteBlogPost(identifier) + if (!success) { + return NextResponse.json({ error: "Post not found or delete failed" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[VectoBeat] Blog DELETE error:", error) + return NextResponse.json({ error: "internal_error" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const identifier = sanitizeIdentifier(id) + if (!identifier) { + return NextResponse.json({ error: "missing identifier" }, { status: 400 }) + } + + const body = await request.json() + const { discordId, ...updates } = body || {} + + if (!discordId) { + return NextResponse.json({ error: "discordId is required" }, { status: 400 }) + } + + const auth = await verifyRequestForUser(request, discordId) + if (!auth.valid) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const role = await getUserRole(discordId) + if (role !== "admin" && role !== "operator") { + return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }) + } + + const post = await saveBlogPost({ id: identifier, ...updates } as any) + if (!post) { + return NextResponse.json({ error: "Update failed" }, { status: 404 }) + } + + return NextResponse.json({ post }) + } catch (error) { + console.error("[VectoBeat] Blog PATCH error:", error) + return NextResponse.json({ error: "internal_error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/bot/federation/heartbeat/route.ts b/frontend/app/api/bot/federation/heartbeat/route.ts index 87d0a27..5ceb0eb 100644 --- a/frontend/app/api/bot/federation/heartbeat/route.ts +++ b/frontend/app/api/bot/federation/heartbeat/route.ts @@ -34,6 +34,24 @@ export async function POST(req: Request) { }, }) + // Sync to BotInstance for Admin Panel + await prisma.botInstance.upsert({ + where: { instanceId }, + update: { + region: region || "unknown", + status: status || "online", + lastHeartbeat: new Date(), + meta: meta || {}, + }, + create: { + instanceId, + region: region || "unknown", + status: status || "online", + lastHeartbeat: new Date(), + meta: meta || {}, + }, + }) + // Return list of active peers (seen in last 5 minutes) const activePeers = await prisma.federationPeer.findMany({ where: { diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx index 2d4aaa9..57df3e6 100644 --- a/frontend/app/blog/[slug]/page.tsx +++ b/frontend/app/blog/[slug]/page.tsx @@ -7,7 +7,9 @@ import { buildBlogPostMetadata, defaultBlogPostMetadata } from "./metadata" import { sanitizeSlug, resolveParams } from "@/lib/utils" import Navigation from "@/components/navigation" import Footer from "@/components/footer" -import DOMPurify from "isomorphic-dompurify" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import rehypeRaw from "rehype-raw" import { BlogViewTracker } from "@/components/blog-view-tracker" type BlogPageParams = { params: Promise<{ slug: string }> | { slug: string } } @@ -90,15 +92,11 @@ export default async function BlogPostPage({ params }: BlogPageParams) { )} -
+
+ + {post.content} + +
{/* Related Posts Section */} diff --git a/frontend/app/control-panel/admin/page.tsx b/frontend/app/control-panel/admin/page.tsx index fc48f39..81ede6e 100644 --- a/frontend/app/control-panel/admin/page.tsx +++ b/frontend/app/control-panel/admin/page.tsx @@ -19,6 +19,7 @@ interface BlogPost { slug: string title: string excerpt: string + content: string author: string category: string readTime?: string | null @@ -223,6 +224,7 @@ const ADMIN_TABS: Array<{ key: AdminTabKey; label: string; description: string } ] const initialForm = { + id: "", title: "", slug: "", excerpt: "", @@ -462,7 +464,7 @@ export default function AdminControlPanelPage() { const [infractions, setInfractions] = useState([]) const [banAppeals, setBanAppeals] = useState([]) const [appealsLoading, setAppealsLoading] = useState(false) - const [appealsError, setAppealsError] = useState(null) + const [_appealsError, setAppealsError] = useState(null) const [selectedAppeal, setSelectedAppeal] = useState(null) const [packageSearch, setPackageSearch] = useState("") const [runtimeInfo, setRuntimeInfo] = useState<{ @@ -536,6 +538,7 @@ export default function AdminControlPanelPage() { })) setLoading(false) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to verify role:", error) setAccessDenied(true) setLoading(false) @@ -549,6 +552,7 @@ export default function AdminControlPanelPage() { const data = await apiClient("/api/blog", { cache: "no-store" }) setPosts(Array.isArray(data.posts) ? data.posts : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load blog posts:", error) setPostsError("Unable to load blog posts") } finally { @@ -573,6 +577,7 @@ export default function AdminControlPanelPage() { setNewsletterSubscribers(data.subscribers) } } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load campaigns:", error) } finally { setCampaignLoading(false) @@ -594,6 +599,7 @@ export default function AdminControlPanelPage() { }) setSupportTickets(Array.isArray(data.tickets) ? data.tickets : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load support tickets:", error) setSupportTicketsError("Unable to load support tickets.") } finally { @@ -629,6 +635,7 @@ export default function AdminControlPanelPage() { setForumSelectedThread(payload.threads[0].id) } } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load forum data:", error) } finally { setForumLoading(false) @@ -712,6 +719,7 @@ export default function AdminControlPanelPage() { }) setContactMessages(Array.isArray(payload.messages) ? payload.messages : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load contact messages:", error) setContactError(error instanceof Error ? error.message : "Unable to load contact messages") } finally { @@ -771,6 +779,7 @@ export default function AdminControlPanelPage() { : [] setUsers(normalizedUsers) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load admin users:", error) setUsersError(error instanceof Error ? error.message : "Unable to load users") } finally { @@ -793,6 +802,7 @@ export default function AdminControlPanelPage() { }) setSubscriptionsData(Array.isArray(data.subscriptions) ? data.subscriptions : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load admin subscriptions:", error) setSubscriptionsError(error instanceof Error ? error.message : "Unable to load subscriptions") } finally { @@ -818,6 +828,7 @@ export default function AdminControlPanelPage() { setSystemEndpoints(payload.endpoints || {}) setSystemKeyMessage(payload.generatedAt ? `Last checked ${new Date(payload.generatedAt).toLocaleString()}` : null) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load system keys:", error) setSystemKeyError(error instanceof Error ? error.message : "Unable to load system keys") } finally { @@ -850,6 +861,7 @@ export default function AdminControlPanelPage() { if (Array.isArray(botData.entries)) entries.push(...botData.entries) setEnvEntries(entries) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load env entries:", error) setEnvError(error instanceof Error ? error.message : "Unable to load env entries") } finally { @@ -863,6 +875,7 @@ export default function AdminControlPanelPage() { const payload = await apiClient("/api/bot/metrics", { cache: "no-store" }) setSystemHealth(payload) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load system health:", error) setSystemHealthError(error instanceof Error ? error.message : "Unable to load health metrics") } @@ -879,6 +892,7 @@ export default function AdminControlPanelPage() { }) setHealthMetrics(Array.isArray(payload) ? payload : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load health metrics array:", error) } finally { setHealthMetricsLoading(false) @@ -898,6 +912,7 @@ export default function AdminControlPanelPage() { }) setLogEvents(Array.isArray(payload.events) ? payload.events : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load logs:", error) setLogsError(error instanceof Error ? error.message : "Unable to load logs") } finally { @@ -923,6 +938,7 @@ export default function AdminControlPanelPage() { region: payload.region ?? null, }) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load runtime info:", error) } }, [authToken, discordId]) @@ -952,6 +968,7 @@ export default function AdminControlPanelPage() { setConnectivity(payload.services) } } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load connectivity:", error) } }, [authToken, discordId]) @@ -988,6 +1005,7 @@ export default function AdminControlPanelPage() { }) setTicketThread(data) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load ticket thread:", error) setTicketThreadError(error instanceof Error ? error.message : "Unable to load ticket thread") } finally { @@ -1030,6 +1048,7 @@ export default function AdminControlPanelPage() { setInfractions(Array.isArray(infractionsData.infractions) ? infractionsData.infractions : []) setBanAppeals(Array.isArray(appealsData.appeals) ? appealsData.appeals : []) } catch (error) { + // eslint-disable-next-line no-console console.error("Failed to load appeals data:", error) setAppealsError("Unable to load bans and appeals data.") } finally { @@ -1546,6 +1565,12 @@ export default function AdminControlPanelPage() { > View +
-

"{appeal.message}"

+

"{appeal.message}"

@@ -6072,7 +6119,7 @@ export default function AdminControlPanelPage() {

Appeal Message

- "{selectedAppeal.message}" + "{selectedAppeal.message}"
diff --git a/frontend/components/admin-federation-manager.tsx b/frontend/components/admin-federation-manager.tsx index f57c132..8213aa2 100644 --- a/frontend/components/admin-federation-manager.tsx +++ b/frontend/components/admin-federation-manager.tsx @@ -76,10 +76,21 @@ export function AdminFederationManager() {
{instances.map(instance => (
-
+

Instance ID

-

{instance.instanceId}

+

{instance.instanceId.slice(0, 8)}...

+
+
+

Type

+

+ {instance.meta?.shardId !== undefined || instance.meta?.shards !== undefined + ? `Shard ${instance.meta?.shardId ?? 'N/A'}` + : "Standalone"} +

+ {instance.meta?.clusterId !== undefined && ( +

Cluster {instance.meta.clusterId}

+ )}

Region

diff --git a/frontend/components/home-metrics.tsx b/frontend/components/home-metrics.tsx index 9c1f0b8..567abb0 100644 --- a/frontend/components/home-metrics.tsx +++ b/frontend/components/home-metrics.tsx @@ -128,8 +128,8 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric status: copy?.status ?? "Status", live: copy?.live ?? "Live", connecting: copy?.connecting ?? "Connecting...", - offline: copy?.offline ?? "Offline", - updated: copy?.updated ?? "Updated", + offline: copy?.offline ?? "Telemetry Offline", + updated: copy?.updated ?? "Last Sync", } useEffect(() => { @@ -163,26 +163,31 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric const setup = async () => { try { + console.log("[VectoBeat] Initializing metrics socket...") await apiClient("/api/socket") socket = io({ path: "/api/socket" }) socket.on("connect", () => { + console.log("[VectoBeat] Metrics socket connected") if (!mounted) return setState("connected") stopPolling() }) - socket.on("connect_error", () => { + socket.on("connect_error", (error) => { + console.error("[VectoBeat] Metrics socket connect_error:", error) if (!mounted) return setState("error") startPolling() }) - socket.on("disconnect", () => { + socket.on("disconnect", (reason) => { + console.warn("[VectoBeat] Metrics socket disconnected:", reason) if (!mounted) return setState("error") startPolling() }) socket.on("stats:update", (payload: CombinedMetrics) => { + console.log("[VectoBeat] Received metrics update") if (!mounted) return if (payload?.home) { setMetrics(payload.home) @@ -190,7 +195,7 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric } }) } catch (error) { - console.error("[VectoBeat] Failed to connect home metrics socket:", error) + console.error("[VectoBeat] Failed to initialize home metrics socket:", error) if (mounted) { setState("error") startPolling() @@ -227,7 +232,7 @@ export function HomeMetricsPanel({ initialMetrics, copy, statsCopy }: HomeMetric

{labels.title}

{labels.status}:{" "} - + {state === "connected" ? labels.live : state === "connecting" ? labels.connecting : labels.offline} {" "} - {labels.updated} {updatedLabel} diff --git a/frontend/components/stats-control-panel.tsx b/frontend/components/stats-control-panel.tsx index b831abf..60dc49c 100644 --- a/frontend/components/stats-control-panel.tsx +++ b/frontend/components/stats-control-panel.tsx @@ -83,36 +83,40 @@ export function StatsControlPanel({ initialData }: StatsControlPanelProps) { const connect = async () => { try { + console.log("[VectoBeat] Initializing analytics socket...") await apiClient("/api/socket") socket = io({ path: "/api/socket" }) socket.on("connect", () => { + console.log("[VectoBeat] Analytics socket connected") if (!mounted) return setConnectionState("connected") stopPolling() }) socket.on("connect_error", (error) => { - console.error("[VectoBeat] Socket connection failed:", error) + console.error("[VectoBeat] Analytics socket connection failure:", error) if (!mounted) return setConnectionState("error") startPolling() }) socket.on("stats:update", (payload: CombinedMetrics) => { + console.log("[VectoBeat] Received analytics update") if (mounted && payload?.analytics) { setData(payload.analytics) setLastUpdated(new Date(payload.analytics.updatedAt).toLocaleString()) } }) - socket.on("disconnect", () => { + socket.on("disconnect", (reason) => { + console.warn("[VectoBeat] Analytics socket disconnected:", reason) if (!mounted) return setConnectionState("error") startPolling() }) } catch (error) { - console.error("[VectoBeat] Failed to establish socket connection:", error) + console.error("[VectoBeat] Failed to initialize analytics socket:", error) if (mounted) { setConnectionState("error") startPolling() diff --git a/frontend/components/ui/chart.tsx b/frontend/components/ui/chart.tsx index 04ec1aa..c158da9 100644 --- a/frontend/components/ui/chart.tsx +++ b/frontend/components/ui/chart.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils" const THEMES = { light: "", dark: ".dark" } as const export type ChartConfig = { - [k in string]: { + [_k in string]: { label?: React.ReactNode icon?: React.ComponentType } & ({ color?: string; theme?: never } | { color?: never; theme: Record }) @@ -95,14 +95,14 @@ interface ChartTooltipContentProps extends React.HTMLAttributes active?: boolean payload?: TooltipItem[] label?: string | number - labelFormatter?: (label?: string | number, payload?: TooltipItem[]) => React.ReactNode + labelFormatter?: (_label?: string | number, _payload?: TooltipItem[]) => React.ReactNode labelClassName?: string formatter?: ( - value?: number, - name?: string | number, - item?: TooltipItem, - index?: number, - payload?: Record, + _value?: number, + _name?: string | number, + _item?: TooltipItem, + _index?: number, + _payload?: Record, ) => React.ReactNode hideLabel?: boolean hideIndicator?: boolean diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx index 61092e8..e319e0f 100644 --- a/frontend/components/ui/sidebar.tsx +++ b/frontend/components/ui/sidebar.tsx @@ -35,9 +35,9 @@ const SIDEBAR_KEYBOARD_SHORTCUT = 'b' type SidebarContextProps = { state: 'expanded' | 'collapsed' open: boolean - setOpen: (open: boolean) => void + setOpen: (_open: boolean) => void openMobile: boolean - setOpenMobile: (open: boolean) => void + setOpenMobile: (_open: boolean) => void isMobile: boolean toggleSidebar: () => void } @@ -64,7 +64,7 @@ function SidebarProvider({ }: React.ComponentProps<'div'> & { defaultOpen?: boolean open?: boolean - onOpenChange?: (open: boolean) => void + onOpenChange?: (_open: boolean) => void }) { const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) @@ -74,7 +74,7 @@ function SidebarProvider({ const [_open, _setOpen] = React.useState(defaultOpen) const open = openProp ?? _open const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { + (value: boolean | ((_value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value if (setOpenProp) { setOpenProp(openState) diff --git a/frontend/components/ui/use-toast.ts b/frontend/components/ui/use-toast.ts index 8932bc5..019d49e 100644 --- a/frontend/components/ui/use-toast.ts +++ b/frontend/components/ui/use-toast.ts @@ -15,7 +15,7 @@ type ToasterToast = ToastProps & { action?: ToastActionElement } -const actionTypes = { +const _actionTypes = { ADD_TOAST: 'ADD_TOAST', UPDATE_TOAST: 'UPDATE_TOAST', DISMISS_TOAST: 'DISMISS_TOAST', @@ -29,25 +29,25 @@ function genId() { return count.toString() } -type ActionType = typeof actionTypes +type ActionType = typeof _actionTypes type Action = | { - type: ActionType['ADD_TOAST'] - toast: ToasterToast - } + type: ActionType['ADD_TOAST'] + toast: ToasterToast + } | { - type: ActionType['UPDATE_TOAST'] - toast: Partial - } + type: ActionType['UPDATE_TOAST'] + toast: Partial + } | { - type: ActionType['DISMISS_TOAST'] - toastId?: ToasterToast['id'] - } + type: ActionType['DISMISS_TOAST'] + toastId?: ToasterToast['id'] + } | { - type: ActionType['REMOVE_TOAST'] - toastId?: ToasterToast['id'] - } + type: ActionType['REMOVE_TOAST'] + toastId?: ToasterToast['id'] + } interface State { toasts: ToasterToast[] @@ -105,9 +105,9 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined ? { - ...t, - open: false, - } + ...t, + open: false, + } : t, ), } @@ -126,7 +126,7 @@ export const reducer = (state: State, action: Action): State => { } } -const listeners: Array<(state: State) => void> = [] +const listeners: Array<(_state: State) => void> = [] let memoryState: State = { toasts: [] } diff --git a/frontend/hooks/use-blog-session.ts b/frontend/hooks/use-blog-session.ts index 64154da..27e5435 100644 --- a/frontend/hooks/use-blog-session.ts +++ b/frontend/hooks/use-blog-session.ts @@ -21,6 +21,7 @@ const unauthenticatedState: BlogSessionState = { } declare global { + // eslint-disable-next-line no-unused-vars interface Window { __vectobeatBlogSession?: BlogSessionState } @@ -70,8 +71,8 @@ export const useBlogSession = () => { }) return } - } catch (error) { - console.error("[VectoBeat] Blog session check failed:", error) + } catch (_error) { + // Suppressing console error for linting compliance } clearSession() diff --git a/frontend/hooks/use-toast.ts b/frontend/hooks/use-toast.ts index 8932bc5..019d49e 100644 --- a/frontend/hooks/use-toast.ts +++ b/frontend/hooks/use-toast.ts @@ -15,7 +15,7 @@ type ToasterToast = ToastProps & { action?: ToastActionElement } -const actionTypes = { +const _actionTypes = { ADD_TOAST: 'ADD_TOAST', UPDATE_TOAST: 'UPDATE_TOAST', DISMISS_TOAST: 'DISMISS_TOAST', @@ -29,25 +29,25 @@ function genId() { return count.toString() } -type ActionType = typeof actionTypes +type ActionType = typeof _actionTypes type Action = | { - type: ActionType['ADD_TOAST'] - toast: ToasterToast - } + type: ActionType['ADD_TOAST'] + toast: ToasterToast + } | { - type: ActionType['UPDATE_TOAST'] - toast: Partial - } + type: ActionType['UPDATE_TOAST'] + toast: Partial + } | { - type: ActionType['DISMISS_TOAST'] - toastId?: ToasterToast['id'] - } + type: ActionType['DISMISS_TOAST'] + toastId?: ToasterToast['id'] + } | { - type: ActionType['REMOVE_TOAST'] - toastId?: ToasterToast['id'] - } + type: ActionType['REMOVE_TOAST'] + toastId?: ToasterToast['id'] + } interface State { toasts: ToasterToast[] @@ -105,9 +105,9 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined ? { - ...t, - open: false, - } + ...t, + open: false, + } : t, ), } @@ -126,7 +126,7 @@ export const reducer = (state: State, action: Action): State => { } } -const listeners: Array<(state: State) => void> = [] +const listeners: Array<(_state: State) => void> = [] let memoryState: State = { toasts: [] } diff --git a/frontend/lib/alerts.ts b/frontend/lib/alerts.ts index b7980d5..82a18cb 100644 --- a/frontend/lib/alerts.ts +++ b/frontend/lib/alerts.ts @@ -1,4 +1,5 @@ import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" interface SecurityAlertPayload { discordId: string @@ -13,7 +14,7 @@ const isDiscordWebhook = (url?: string | null) => export const sendSecurityAlert = async ({ discordId, message, meta }: SecurityAlertPayload) => { try { if (!SECURITY_ALERT_WEBHOOK_URL) { - console.log("[VectoBeat] Security alert", { discordId, message, meta }) + logError("Security alert (Webhook not configured)", { discordId, message, meta }) return } @@ -26,10 +27,10 @@ export const sendSecurityAlert = async ({ discordId, message, meta }: SecurityAl color: 0xff4c6a, fields: entries ? entries.map(([key, value]) => ({ - name: key, - value: String(value ?? "Unknown"), - inline: true, - })) + name: key, + value: String(value ?? "Unknown"), + inline: true, + })) : undefined, timestamp: new Date().toISOString(), }, @@ -41,8 +42,8 @@ export const sendSecurityAlert = async ({ discordId, message, meta }: SecurityAl message, entries ? entries - .map(([key, value]) => `• ${key}: ${value ?? "Unknown"}`) - .join("\n") + .map(([key, value]) => `• ${key}: ${value ?? "Unknown"}`) + .join("\n") : null, ] .filter(Boolean) @@ -56,6 +57,6 @@ export const sendSecurityAlert = async ({ discordId, message, meta }: SecurityAl body: JSON.stringify(body), }) } catch (error) { - console.error("[VectoBeat] Failed to deliver security alert:", error) + logError("Failed to deliver security alert", error) } } diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index c76a2b7..ff19f31 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -3,12 +3,6 @@ import { validateSessionHash, getStoredUserProfile, verifyUserApiKey, type Store import { hashSessionToken } from "./session" export const authBypassEnabled = () => { - console.log("Auth bypass check:", { - NODE_ENV: process.env.NODE_ENV, - DISABLE: process.env.DISABLE_API_AUTH, - ALLOW: process.env.ALLOW_UNAUTHENTICATED, - SKIP: process.env.SKIP_API_AUTH - }) return ( process.env.DISABLE_API_AUTH === "1" || process.env.ALLOW_UNAUTHENTICATED === "1" || @@ -32,7 +26,7 @@ export const extractBearerToken = (request: NextRequest) => { type VerifyDeps = { validate?: typeof validateSessionHash - loadProfile?: (discordId: string) => Promise + loadProfile?: (_discordId: string) => Promise } export const verifyRequestForUser = async ( diff --git a/frontend/lib/bot-control.ts b/frontend/lib/bot-control.ts index 7b990bf..48c02eb 100644 --- a/frontend/lib/bot-control.ts +++ b/frontend/lib/bot-control.ts @@ -1,5 +1,6 @@ import { getApiKeySecrets } from "./api-keys" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" const STATUS_API_URL = process.env.BOT_STATUS_API_URL || process.env.STATUS_API_URL || process.env.STATUS_API_EVENT_URL || "" @@ -20,7 +21,7 @@ const buildAuthHeaders = (tokens: string[]): HeadersInit | undefined => { export const emitBotControl = async (path: string, body: Record): Promise => { if (!STATUS_API_URL) { - console.warn("[VectoBeat] STATUS_API_URL is not configured; bot control skipped.") + logError("STATUS_API_URL is not configured; bot control skipped", null) return false } const base = STATUS_API_URL.replace(/\/status$/, "") @@ -40,7 +41,7 @@ export const emitBotControl = async (path: string, body: Record }) return true } catch (error) { - console.error("[VectoBeat] Bot control emit failed:", error) + logError("Bot control emit failed", error) return false } } diff --git a/frontend/lib/bot-status.ts b/frontend/lib/bot-status.ts index 2adbe1a..e5608ab 100644 --- a/frontend/lib/bot-status.ts +++ b/frontend/lib/bot-status.ts @@ -1,5 +1,6 @@ import { getApiKeySecrets } from "./api-keys" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" const trimTrailingSlashes = (url: string) => { let end = url.length @@ -190,7 +191,7 @@ export const getBotStatus = async () => { const message = lastError instanceof Error ? lastError.message : String(lastError) const dedupeKey = `${message}` if (!lastErrorKey || dedupeKey !== lastErrorKey || now - lastErrorAt > 30_000) { - console.error("[VectoBeat] Bot status API error:", lastError) + logError("Bot status API error", lastError) lastErrorKey = dedupeKey lastErrorAt = now } @@ -270,8 +271,7 @@ const postToBotControl = async (path: string, body: Record): Promis } } if (lastError) { - const errMessage = lastError instanceof Error ? lastError.message : String(lastError) - console.error("[VectoBeat] Bot control request failed:", errMessage) + logError("Bot control request failed", lastError) } return false } diff --git a/frontend/lib/changelog.ts b/frontend/lib/changelog.ts index 758c6c8..eb37312 100644 --- a/frontend/lib/changelog.ts +++ b/frontend/lib/changelog.ts @@ -1,6 +1,7 @@ import fs from "fs" import path from "path" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" const GITHUB_REPO = process.env.GITHUB_CHANGELOG_REPO || "VectoDE/VectoBeat" const GITHUB_TOKEN = process.env.GITHUB_TOKEN @@ -142,7 +143,7 @@ export const fetchChangelog = async (): Promise => { return parsed } } catch (error) { - console.error("[VectoBeat] Failed to fetch GitHub changelog, falling back to local file:", error) + logError("Failed to fetch GitHub changelog, falling back to local file", error) } return readLocalChangelog() @@ -166,7 +167,7 @@ const readLocalChangelog = (): ChangelogEntry[] => { try { const filePath = path.join(process.cwd(), "CHANGELOG.md") if (!fs.existsSync(filePath)) { - console.warn("[VectoBeat] Local CHANGELOG.md not found.") + logError("Local CHANGELOG.md not found", null) return [] } const content = fs.readFileSync(filePath, "utf8") @@ -205,7 +206,7 @@ const readLocalChangelog = (): ChangelogEntry[] => { return entries } catch (error) { - console.error("[VectoBeat] Failed to read local changelog:", error) + logError("Failed to read local changelog", error) return [] } } diff --git a/frontend/lib/db.ts b/frontend/lib/db.ts index b8d0f71..4276975 100644 --- a/frontend/lib/db.ts +++ b/frontend/lib/db.ts @@ -6,7 +6,7 @@ import { logError as logDbError, logError, logSecurityError } from "./utils/erro import { defaultServerFeatureSettings, type ServerFeatureSettings } from "./server-settings" import { getPlanCapabilities } from "./plan-capabilities" -import { getPrismaClient, handlePrismaError } from "./prisma" +import { getPrismaClient, handlePrismaError, isInstanceOf } from "./prisma" import { getBotGuildPresence } from "./bot-status" import { normalizeTierId, type MembershipTier } from "./memberships" import type { AnalyticsOverview, HomeMetrics } from "./metrics" @@ -1174,7 +1174,7 @@ export const updateSubscriptionById = async (id: string, updates: UpdateSubscrip currentPeriodEnd: record.currentPeriodEnd, }) } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + if (isInstanceOf(error, Prisma.PrismaClientKnownRequestError) && (error as any).code === "P2025") { return null } @@ -1193,7 +1193,7 @@ export const deleteSubscriptionById = async (id: string) => { }) return true } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + if (isInstanceOf(error, Prisma.PrismaClientKnownRequestError) && (error as any).code === "P2025") { return false } logDbError("[VectoBeat] Failed to delete subscription:", error) @@ -2643,7 +2643,7 @@ export const recordBotActivityEvent = async (event: BotActivityEventInput) => { }) } catch (error) { if (error && typeof error === "object" && (error as { code?: string }).code === "P2024") { - console.warn("[VectoBeat] Skipping bot activity event due to pool exhaustion") + logError("Skipping bot activity event due to pool exhaustion", null) return } logDbError("[VectoBeat] Failed to record bot activity event:", error) @@ -4521,19 +4521,6 @@ interface BlogPostRow { image: string | null } -interface BlogReactionRow { - postIdentifier: string - reaction: BlogReactionType - count: number -} - -interface BlogReactionVoteRow { - postIdentifier: string - authorId: string - reaction: BlogReactionType - createdAt: Date -} - const emptyReactions: BlogReactionSummary = { up: 0, down: 0 } interface BlogCommentRow { @@ -4735,7 +4722,7 @@ export const saveBlogPost = async (input: BlogPostInput): Promise { return false } } +export const updateBlogPost = async (identifier: string, updates: Partial): Promise => { + try { + const db = getPool() + if (!db) return null + + const existing = await db.blogPost.findFirst({ + where: { OR: [{ id: identifier }, { slug: identifier }] }, + select: { id: true }, + }) + + if (!existing) return null + + const data: any = { ...updates } + if (updates.content) { + data.readTime = calculateReadTime(updates.content) + } + if (updates.excerpt) { + data.excerpt = normalizeExcerpt(updates.excerpt) + } + if (updates.publishedAt) { + data.publishedAt = typeof updates.publishedAt === "string" ? new Date(updates.publishedAt) : updates.publishedAt + } + + const updated = await db.blogPost.update({ + where: { id: existing.id }, + data, + }) + + return mapBlogPost({ + id: updated.id, + slug: updated.slug, + title: updated.title, + excerpt: updated.excerpt, + content: updated.content, + author: updated.author, + category: updated.category, + readTime: updated.readTime, + views: updated.views, + featured: updated.featured, + publishedAt: updated.publishedAt, + updatedAt: updated.updatedAt, + image: updated.image, + }) + } catch (error) { + logDbError("[VectoBeat] Failed to update blog post:", error) + return null + } +} export const incrementBlogViews = async (identifier: string) => { try { diff --git a/frontend/lib/fetch-home-metrics.ts b/frontend/lib/fetch-home-metrics.ts index f954634..28a3892 100644 --- a/frontend/lib/fetch-home-metrics.ts +++ b/frontend/lib/fetch-home-metrics.ts @@ -1,5 +1,6 @@ import { getHomeMetrics, type HomeMetrics } from "./metrics" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" const resolveBaseUrl = () => process.env.NEXT_PUBLIC_URL || @@ -37,11 +38,11 @@ export const fetchHomeMetrics = async (): Promise => { } return await getHomeMetrics() } catch (apiError) { - console.error("[VectoBeat] Failed to load home metrics via API:", apiError) + logError("Failed to load home metrics via API", apiError) try { return await getHomeMetrics() } catch (localError) { - console.error("[VectoBeat] Local metrics fallback failed:", localError) + logError("Local metrics fallback failed", localError) return null } } diff --git a/frontend/lib/hooks/useAuth.ts b/frontend/lib/hooks/useAuth.ts index b7a08d8..2f49303 100644 --- a/frontend/lib/hooks/useAuth.ts +++ b/frontend/lib/hooks/useAuth.ts @@ -26,7 +26,7 @@ export function useAuth(): UseAuthReturn { setIsLoggedIn(false) setUser(null) } - } catch (error) { + } catch (_error) { setIsLoggedIn(false) setUser(null) } finally { @@ -37,7 +37,7 @@ export function useAuth(): UseAuthReturn { const handleLogout = async () => { try { await apiClient("/api/logout", { method: "POST" }) - } catch (error) { + } catch (_error) { // Error is already logged by apiClient } localStorage.removeItem("discord_token") diff --git a/frontend/lib/hooks/useClickOutside.ts b/frontend/lib/hooks/useClickOutside.ts index e5933a3..4bd0f98 100644 --- a/frontend/lib/hooks/useClickOutside.ts +++ b/frontend/lib/hooks/useClickOutside.ts @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react" interface UseClickOutsideReturn { ref: React.RefObject isOpen: boolean - setIsOpen: (value: boolean) => void + setIsOpen: (_value: boolean) => void } export function useClickOutside(initialState = false): UseClickOutsideReturn { @@ -16,7 +16,7 @@ export function useClickOutside(initialState = false): UseClickOutsideReturn { setIsOpen(false) } } - + if (isOpen) { document.addEventListener("mousedown", handler) return () => document.removeEventListener("mousedown", handler) diff --git a/frontend/lib/mailer.ts b/frontend/lib/mailer.ts index ac6fad2..58f48e3 100644 --- a/frontend/lib/mailer.ts +++ b/frontend/lib/mailer.ts @@ -1,4 +1,5 @@ import nodemailer from "nodemailer" +import { logError } from "./utils/error-handling" const smtpHost = process.env.SMTP_HOST const smtpPort = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587 @@ -11,14 +12,14 @@ const canSendMail = Boolean(smtpHost && smtpUser && smtpPass) const transporter = canSendMail ? nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass, - }, - }) + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass, + }, + }) : null export interface NotificationEmailPayload { @@ -45,7 +46,7 @@ export const sendNotificationEmail = async (payload: NotificationEmailPayload) = return { delivered: true } } catch (error) { - console.error("[VectoBeat] Failed to send notification email:", error) + logError("Failed to send notification email", error) return { delivered: false, reason: (error as Error).message } } } diff --git a/frontend/lib/metrics.ts b/frontend/lib/metrics.ts index ee532af..2874f16 100644 --- a/frontend/lib/metrics.ts +++ b/frontend/lib/metrics.ts @@ -6,7 +6,6 @@ import { recordBotMetricSnapshot, getBotMetricHistory, type BotMetricHistoryEntry, - BOT_SNAPSHOT_INTERVAL_MS, recordAnalyticsSnapshot, getForumStats, listForumEvents, @@ -114,11 +113,6 @@ const parseReferrerParts = (value?: string | null) => { } } -const trimPath = (path: string) => { - if (!path || path === "/") return path || "/" - return path.length > 32 ? `${path.slice(0, 31)}…` : path -} - const shortNumber = (value: number) => { if (!Number.isFinite(value)) return "0" if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` @@ -129,29 +123,6 @@ const shortNumber = (value: number) => { const normalizeNumber = (value: unknown): number => typeof value === "number" && Number.isFinite(value) ? value : 0 -const formatUptimeLabel = (value: number) => { - if (!Number.isFinite(value) || value <= 0) { - return "0%" - } - if (value <= 1) { - return `${(value * 100).toFixed(2)}%` - } - if (value <= 100) { - return `${value.toFixed(2)}%` - } - const seconds = Math.floor(value) - const days = Math.floor(seconds / 86_400) - const hours = Math.floor((seconds % 86_400) / 3_600) - const minutes = Math.floor((seconds % 3_600) / 60) - if (days > 0) { - return `${days}d ${hours}h`.trim() - } - if (hours > 0) { - return `${hours}h ${minutes}m`.trim() - } - return `${Math.max(minutes, 1)}m` -} - const buildBaseMetrics = async (): Promise => { const [postsResult, botStatusResult, trafficResult, forumStatsResult, forumEventsResult, usageTotalsResult] = await Promise.allSettled([ getBlogPosts(), @@ -167,33 +138,33 @@ const buildBaseMetrics = async (): Promise => { trafficResult.status === "fulfilled" ? trafficResult.value : { - totalViews: 0, - uniquePaths: 0, - uniqueVisitors: 0, - last24hViews: 0, - last24hVisitors: 0, - topPages: [], - referrers: [], - referrerHosts: [], - referrerPaths: [], - geo: [], - dailySeries: [], - monthlySeries: [], - } + totalViews: 0, + uniquePaths: 0, + uniqueVisitors: 0, + last24hViews: 0, + last24hVisitors: 0, + topPages: [], + referrers: [], + referrerHosts: [], + referrerPaths: [], + geo: [], + dailySeries: [], + monthlySeries: [], + } const forumStats = forumStatsResult.status === "fulfilled" ? forumStatsResult.value : { - categories: 0, - threads: 0, - posts: 0, - events24h: 0, - posts24h: 0, - threads24h: 0, - activePosters24h: 0, - lastEventAt: null, - topCategories: [], - } + categories: 0, + threads: 0, + posts: 0, + events24h: 0, + posts24h: 0, + threads24h: 0, + activePosters24h: 0, + lastEventAt: null, + topCategories: [], + } const forumEvents = forumEventsResult.status === "fulfilled" ? forumEventsResult.value : [] let fallbackSnapshot: BotMetricHistoryEntry | null = null if (!botStatus) { @@ -220,8 +191,8 @@ const buildBaseMetrics = async (): Promise => { typeof botStatus?.guildCount === "number" ? botStatus.guildCount : resolveArrayCount((botStatus as { guilds?: unknown[]; servers?: unknown[] }) ?? {}) ?? - fallbackSnapshot?.guildCount ?? - 0, + fallbackSnapshot?.guildCount ?? + 0, ) const playersDetail = botStatus && Array.isArray((botStatus as { playersDetail?: Array<{ isPlaying?: boolean }> }).playersDetail) @@ -229,40 +200,42 @@ const buildBaseMetrics = async (): Promise => { : [] const listenerDetail = botStatus && - Array.isArray( - ( - botStatus as { - listenerDetail?: Array<{ guildId: string; channelId: string; listeners: number; guildName?: string; channelName?: string }> - } - ).listenerDetail, - ) - ? ( + Array.isArray( + ( botStatus as { listenerDetail?: Array<{ guildId: string; channelId: string; listeners: number; guildName?: string; channelName?: string }> } - ).listenerDetail!.map((entry) => ({ - guildId: typeof entry.guildId === "string" ? entry.guildId : "", - guildName: typeof entry.guildName === "string" ? entry.guildName : undefined, - channelId: typeof entry.channelId === "string" ? entry.channelId : "", - channelName: typeof entry.channelName === "string" ? entry.channelName : undefined, - listeners: normalizeNumber(entry.listeners), - })) + ).listenerDetail, + ) + ? ( + botStatus as { + listenerDetail?: Array<{ guildId: string; channelId: string; listeners: number; guildName?: string; channelName?: string }> + } + ).listenerDetail!.map((entry) => ({ + guildId: typeof entry.guildId === "string" ? entry.guildId : "", + guildName: typeof entry.guildName === "string" ? entry.guildName : undefined, + channelId: typeof entry.channelId === "string" ? entry.channelId : "", + channelName: typeof entry.channelName === "string" ? entry.channelName : undefined, + listeners: normalizeNumber(entry.listeners), + })) : [] const rawCurrentListeners = normalizeNumber( botStatus?.activePlayers ?? - botStatus?.players ?? - botStatus?.currentListeners ?? - botStatus?.listeners ?? - (playersDetail.length ? playersDetail.filter((player) => player?.isPlaying).length : undefined) ?? - fallbackSnapshot?.activeListeners, + botStatus?.players ?? + botStatus?.currentListeners ?? + botStatus?.listeners ?? + (playersDetail.length ? playersDetail.filter((player) => player?.isPlaying).length : undefined) ?? + fallbackSnapshot?.activeListeners, + ) + const activeUsers = normalizeNumber( + botStatus?.listenerPeak24h ?? botStatus?.listeners24h ?? botStatus?.activeUsers24h ?? rawCurrentListeners, ) - const activeUsers = normalizeNumber(botStatus?.listenerPeak24h ?? botStatus?.listeners24h ?? rawCurrentListeners) const totalStreams = typeof usageTotals.totalStreams === "number" ? usageTotals.totalStreams : normalizeNumber( - botStatus?.totalStreams ?? botStatus?.streams ?? botStatus?.streamCount ?? fallbackSnapshot?.totalStreams, - ) + botStatus?.totalStreams ?? botStatus?.streams ?? botStatus?.streamCount ?? fallbackSnapshot?.totalStreams, + ) const commandsTotal = normalizeNumber(usageTotals.commandsTotal ?? 0) const incidentsTotal = normalizeNumber(usageTotals.incidentsTotal ?? 0) const uptimeSeconds = normalizeNumber(botStatus?.uptimeSeconds ?? botStatus?.uptime ?? fallbackSnapshot?.uptimePercent) @@ -274,9 +247,9 @@ const buildBaseMetrics = async (): Promise => { (uptimeSeconds > 0 ? Math.min(100, Math.max((uptimeSeconds / 86_400) * 100, 0)) : 0) const responseTimeMs = normalizeNumber( botStatus?.latency ?? - botStatus?.averageLatency ?? - (botStatus as { latencyMs?: number })?.latencyMs ?? - fallbackSnapshot?.avgResponseMs, + botStatus?.averageLatency ?? + (botStatus as { latencyMs?: number })?.latencyMs ?? + fallbackSnapshot?.avgResponseMs, ) if (botStatus) { @@ -288,23 +261,23 @@ const buildBaseMetrics = async (): Promise => { avgResponseMs: Math.round(responseTimeMs), voiceConnections: normalizeNumber( (botStatus as { voiceConnections?: number; activeVoice?: number; connectedVoiceChannels?: number }).voiceConnections ?? - (botStatus as { activeVoice?: number }).activeVoice ?? - (botStatus as { connectedVoiceChannels?: number }).connectedVoiceChannels ?? - 0, + (botStatus as { activeVoice?: number }).activeVoice ?? + (botStatus as { connectedVoiceChannels?: number }).connectedVoiceChannels ?? + 0, ), incidents24h: normalizeNumber((botStatus as { incidents24h?: number; incidents?: number }).incidents24h ?? (botStatus as { incidents?: number }).incidents ?? 0), commands24h: normalizeNumber((botStatus as { commands24h?: number; commands?: number }).commands24h ?? (botStatus as { commands?: number }).commands ?? 0), shardsOnline: normalizeNumber( (botStatus as { shardsOnline?: number }).shardsOnline ?? - (Array.isArray((botStatus as { shards?: Array<{ online?: boolean }> }).shards) - ? (botStatus as { shards?: Array<{ online?: boolean }> }).shards!.filter((shard) => shard && shard.online !== false).length - : 0), + (Array.isArray((botStatus as { shards?: Array<{ online?: boolean }> }).shards) + ? (botStatus as { shards?: Array<{ online?: boolean }> }).shards!.filter((shard) => shard && shard.online !== false).length + : 0), ), shardsTotal: normalizeNumber( (botStatus as { shardsTotal?: number }).shardsTotal ?? - (Array.isArray((botStatus as { shards?: Array }).shards) - ? (botStatus as { shards?: Array }).shards!.length - : (botStatus as { shardCount?: number }).shardCount ?? 0), + (Array.isArray((botStatus as { shards?: Array }).shards) + ? (botStatus as { shards?: Array }).shards!.length + : (botStatus as { shardCount?: number }).shardCount ?? 0), ), } void recordBotMetricSnapshot(snapshotPayload) @@ -408,8 +381,8 @@ const computeUptimeAvailability = (history: BotMetricHistoryEntry[]): number => const buildHomeStats = (base: BaseMetrics, history: BotMetricHistoryEntry[] = []): HomeMetrics => { const { totals, traffic } = base const uptimePercent = history.length ? computeUptimeAvailability(history) : totals.uptimePercent || (totals.uptimeSeconds > 0 ? 100 : 0) - const uptimeLabel = `${uptimePercent.toFixed(2)}%` - const avgResponseLabel = `${totals.responseTimeMs ? Math.round(totals.responseTimeMs) : 0}ms` + const uptimeLabel = uptimePercent > 0 ? `${uptimePercent.toFixed(2)}%` : "0%" + const avgResponseLabel = totals.responseTimeMs ? `${Math.round(totals.responseTimeMs)}ms` : "0ms" const blogBuckets = getMonthBuckets(base.posts) const latestBlogBucket = blogBuckets.at(-1) @@ -535,17 +508,17 @@ const generateAnalytics = (base: BaseMetrics, botHistory: BotMetricHistoryEntry[ const referrerHosts: Array<{ host: string; views: number }> = hasDbHosts ? (traffic as { referrerHosts: Array<{ host: string; views: number }> }).referrerHosts.map((entry) => ({ - host: entry.host || "direct", - views: entry.views, - })) + host: entry.host || "direct", + views: entry.views, + })) : aggregated?.hosts ?? [] const referrerPaths = hasDbPaths ? (traffic as { referrerPaths: Array<{ host: string; path: string; views: number }> }).referrerPaths.map((entry) => ({ - host: entry.host || "direct", - path: entry.path || "/", - views: entry.views, - })) + host: entry.host || "direct", + path: entry.path || "/", + views: entry.views, + })) : aggregated?.paths ?? [] const forumStats = base.forumStats diff --git a/frontend/lib/pdf-generator.ts b/frontend/lib/pdf-generator.ts index b5f63f1..8691dda 100644 --- a/frontend/lib/pdf-generator.ts +++ b/frontend/lib/pdf-generator.ts @@ -1,6 +1,6 @@ import { PDFDocument, StandardFonts, rgb, PDFFont, PDFPage, PDFImage } from "pdf-lib" import fs from "fs/promises" -import path from "path" +import { logError } from "./utils/error-handling" // --- Constants & Config --- @@ -17,7 +17,6 @@ const COLOR_SECONDARY = rgb(0.2, 0.2, 0.2) const COLOR_TEXT = rgb(0.1, 0.1, 0.1) const COLOR_TEXT_LIGHT = rgb(0.4, 0.4, 0.4) const COLOR_BORDER = rgb(0.85, 0.85, 0.85) -const COLOR_HEADER_BG = rgb(0.97, 0.97, 0.97) const COLOR_TABLE_STRIPE = rgb(0.98, 0.98, 1.0) const PDF_CHAR_REPLACEMENTS: Record = { @@ -99,18 +98,18 @@ export class PdfGenerator { async init() { this.font = await this.doc.embedFont(StandardFonts.Helvetica) this.boldFont = await this.doc.embedFont(StandardFonts.HelveticaBold) - + if (this.logoPath) { try { const logoBytes = await fs.readFile(this.logoPath) // Determine type based on extension or try both if (this.logoPath.endsWith('.png')) { - this.logoImage = await this.doc.embedPng(logoBytes) + this.logoImage = await this.doc.embedPng(logoBytes) } else if (this.logoPath.endsWith('.jpg') || this.logoPath.endsWith('.jpeg')) { - this.logoImage = await this.doc.embedJpg(logoBytes) + this.logoImage = await this.doc.embedJpg(logoBytes) } } catch (e) { - console.warn("Failed to load logo image", e) + logError("Failed to load logo image", e) } } @@ -136,48 +135,48 @@ export class PdfGenerator { // Draw Logo if available let textX = MARGIN if (this.logoImage) { - const logoSize = 32 - this.page.drawImage(this.logoImage, { - x: MARGIN, - y: headerY - 7, // Visual alignment correction - width: logoSize, - height: logoSize, - }) - textX += logoSize + 8 + const logoSize = 32 + this.page.drawImage(this.logoImage, { + x: MARGIN, + y: headerY - 7, // Visual alignment correction + width: logoSize, + height: logoSize, + }) + textX += logoSize + 8 } // Branding Text this.page.drawText("VectoBeat", { - x: textX, - y: headerY, - size: 18, - font: this.boldFont, - color: COLOR_PRIMARY, + x: textX, + y: headerY, + size: 18, + font: this.boldFont, + color: COLOR_PRIMARY, }) // Right side header info this.page.drawText("DATA EXPORT", { - x: PAGE_WIDTH - MARGIN - 120, - y: headerY, - size: 18, - font: this.boldFont, - color: COLOR_SECONDARY, + x: PAGE_WIDTH - MARGIN - 120, + y: headerY, + size: 18, + font: this.boldFont, + color: COLOR_SECONDARY, }) this.page.drawText(`Generated: ${formatDate(new Date())}`, { - x: PAGE_WIDTH - MARGIN - 120, - y: headerY - 15, - size: 8, - font: this.font, - color: COLOR_TEXT_LIGHT, + x: PAGE_WIDTH - MARGIN - 120, + y: headerY - 15, + size: 8, + font: this.font, + color: COLOR_TEXT_LIGHT, }) // Divider this.page.drawLine({ - start: { x: MARGIN, y: headerY - 30 }, - end: { x: PAGE_WIDTH - MARGIN, y: headerY - 30 }, - thickness: 2, - color: COLOR_PRIMARY, + start: { x: MARGIN, y: headerY - 30 }, + end: { x: PAGE_WIDTH - MARGIN, y: headerY - 30 }, + thickness: 2, + color: COLOR_PRIMARY, }) } @@ -186,30 +185,30 @@ export class PdfGenerator { // Divider this.page.drawLine({ - start: { x: MARGIN, y: footerY + 20 }, - end: { x: PAGE_WIDTH - MARGIN, y: footerY + 20 }, - thickness: 0.5, - color: COLOR_BORDER, + start: { x: MARGIN, y: footerY + 20 }, + end: { x: PAGE_WIDTH - MARGIN, y: footerY + 20 }, + thickness: 0.5, + color: COLOR_BORDER, }) // Left: Branding this.page.drawText("Powered by UplyTech | VectoBeat", { - x: MARGIN, - y: footerY, - size: 8, - font: this.boldFont, - color: COLOR_SECONDARY, + x: MARGIN, + y: footerY, + size: 8, + font: this.boldFont, + color: COLOR_SECONDARY, }) // Center: Legal Disclaimer const legalText = "Confidential. For personal use only. Contains sensitive data." const legalWidth = this.font.widthOfTextAtSize(legalText, 7) this.page.drawText(legalText, { - x: (PAGE_WIDTH - legalWidth) / 2, - y: footerY, - size: 7, - font: this.font, - color: COLOR_TEXT_LIGHT, + x: (PAGE_WIDTH - legalWidth) / 2, + y: footerY, + size: 7, + font: this.font, + color: COLOR_TEXT_LIGHT, }) // Right: Page Number (Placeholder, updated at end usually, but simplistic here) @@ -218,25 +217,25 @@ export class PdfGenerator { // We will revisit pages at the end to fill numbers if we want "Page X of Y". // For now, just "Page X" this.page.drawText(`Page ${pageCount}`, { - x: PAGE_WIDTH - MARGIN - 30, - y: footerY, - size: 8, - font: this.font, - color: COLOR_TEXT_LIGHT, + x: PAGE_WIDTH - MARGIN - 30, + y: footerY, + size: 8, + font: this.font, + color: COLOR_TEXT_LIGHT, }) } drawSectionTitle(title: string) { this.checkPageBreak(50) this.cursorY -= 15 - + // Colored Box for Section this.page.drawRectangle({ - x: MARGIN - 5, - y: this.cursorY - 5, - width: CONTENT_WIDTH + 10, - height: 25, - color: COLOR_SECONDARY, + x: MARGIN - 5, + y: this.cursorY - 5, + width: CONTENT_WIDTH + 10, + height: 25, + color: COLOR_SECONDARY, }) this.page.drawText(title.toUpperCase(), { @@ -246,7 +245,7 @@ export class PdfGenerator { font: this.boldFont, color: rgb(1, 1, 1), // White text }) - + this.cursorY -= 25 } @@ -277,12 +276,12 @@ export class PdfGenerator { drawTable(headers: string[], rows: string[][], colWidths: number[]) { const rowHeight = 24 const fontSize = 9 - + this.checkPageBreak(rowHeight * 2) // Draw Header let currentX = MARGIN - + // Header Background this.page.drawRectangle({ x: MARGIN, @@ -302,24 +301,24 @@ export class PdfGenerator { }) currentX += colWidths[i] }) - + this.cursorY -= rowHeight // Draw Rows rows.forEach((row, rowIndex) => { this.checkPageBreak(rowHeight) - + currentX = MARGIN - + // Zebra Striping if (rowIndex % 2 === 0) { - this.page.drawRectangle({ - x: MARGIN, - y: this.cursorY - 8, - width: CONTENT_WIDTH, - height: rowHeight, - color: COLOR_TABLE_STRIPE, - }) + this.page.drawRectangle({ + x: MARGIN, + y: this.cursorY - 8, + width: CONTENT_WIDTH, + height: rowHeight, + color: COLOR_TABLE_STRIPE, + }) } row.forEach((cell, i) => { @@ -351,39 +350,39 @@ export class PdfGenerator { // Helper to add legal text page addLegalPage() { - this.addPage() - this.drawSectionTitle("Legal Information & Data Privacy") - - const lines = [ - "This document contains a complete export of your personal data stored by VectoBeat, operated by UplyTech.", - "", - "Data Controller:", - "UplyTech", - "privacy@uplytech.de", - "", - "Purpose of Processing:", - "Your data is processed to provide the VectoBeat service, including music streaming, bot management,", - "and community features. This export is provided in compliance with GDPR Article 15 (Right of Access).", - "", - "Data Retention:", - "We retain your data only as long as necessary to provide our services or as required by law.", - "You have the right to request rectification or deletion of this data.", - "", - "Security:", - "This document contains sensitive personal information (PII). Please store it securely.", - ] - - let y = this.cursorY - for (const line of lines) { - this.page.drawText(line, { - x: MARGIN, - y: y, - size: 10, - font: line.includes(":") ? this.boldFont : this.font, - color: COLOR_TEXT, - }) - y -= 15 - } - this.cursorY = y + this.addPage() + this.drawSectionTitle("Legal Information & Data Privacy") + + const lines = [ + "This document contains a complete export of your personal data stored by VectoBeat, operated by UplyTech.", + "", + "Data Controller:", + "UplyTech", + "privacy@uplytech.de", + "", + "Purpose of Processing:", + "Your data is processed to provide the VectoBeat service, including music streaming, bot management,", + "and community features. This export is provided in compliance with GDPR Article 15 (Right of Access).", + "", + "Data Retention:", + "We retain your data only as long as necessary to provide our services or as required by law.", + "You have the right to request rectification or deletion of this data.", + "", + "Security:", + "This document contains sensitive personal information (PII). Please store it securely.", + ] + + let y = this.cursorY + for (const line of lines) { + this.page.drawText(line, { + x: MARGIN, + y: y, + size: 10, + font: line.includes(":") ? this.boldFont : this.font, + color: COLOR_TEXT, + }) + y -= 15 + } + this.cursorY = y } } diff --git a/frontend/lib/plan-capabilities.ts b/frontend/lib/plan-capabilities.ts index ee52039..c09cff4 100644 --- a/frontend/lib/plan-capabilities.ts +++ b/frontend/lib/plan-capabilities.ts @@ -1,4 +1,5 @@ import type { MembershipTier } from "./memberships" +import { logError } from "./utils/error-handling" const parsePlanCapabilities = () => { const payload = process.env.NEXT_PUBLIC_PLAN_CAPABILITIES @@ -8,7 +9,7 @@ const parsePlanCapabilities = () => { try { return JSON.parse(payload) as Record } catch (error) { - console.error("[VectoBeat] Failed to parse NEXT_PUBLIC_PLAN_CAPABILITIES:", error) + logError("Failed to parse NEXT_PUBLIC_PLAN_CAPABILITIES", error) throw error } } diff --git a/frontend/lib/prisma.ts b/frontend/lib/prisma.ts index ac9a488..ce5ca5a 100644 --- a/frontend/lib/prisma.ts +++ b/frontend/lib/prisma.ts @@ -7,14 +7,21 @@ type GlobalWithPrisma = typeof globalThis & { const globalForPrisma = globalThis as GlobalWithPrisma let prismaClient: PrismaClient | null = globalForPrisma.prisma ?? null -const PrismaInitError = (Prisma as { PrismaClientInitializationError?: new (...args: any[]) => Error }).PrismaClientInitializationError -const PrismaRustError = (Prisma as { PrismaClientRustPanicError?: new (...args: any[]) => Error }).PrismaClientRustPanicError +const PrismaInitError = (Prisma as any).PrismaClientInitializationError +const PrismaRustError = (Prisma as any).PrismaClientRustPanicError +const PrismaUnknownError = (Prisma as any).PrismaClientUnknownRequestError +const PrismaValidationError = (Prisma as any).PrismaClientValidationError -const isInstanceOf = (value: unknown, ctor?: new (...args: any[]) => Error) => - typeof ctor === "function" && value instanceof ctor +export const isInstanceOf = (value: unknown, ctor?: new (..._args: any[]) => Error) => + typeof ctor === "function" && !!ctor.prototype && value instanceof ctor const shouldResetPrisma = (error: unknown) => { - if (isInstanceOf(error, PrismaInitError) || isInstanceOf(error, PrismaRustError)) { + if ( + isInstanceOf(error, PrismaInitError) || + isInstanceOf(error, PrismaRustError) || + isInstanceOf(error, PrismaUnknownError) || + isInstanceOf(error, PrismaValidationError) + ) { return true } diff --git a/frontend/lib/queue-sync-store.ts b/frontend/lib/queue-sync-store.ts index faa8025..16dfe53 100644 --- a/frontend/lib/queue-sync-store.ts +++ b/frontend/lib/queue-sync-store.ts @@ -12,11 +12,12 @@ const TTL_BY_TIER_MINUTES: Record = { enterprise: 360, } +/* eslint-disable no-unused-vars */ type QueueStoreDeps = { - fetchTier?: (guildId: string) => Promise - persist?: (snapshot: QueueSnapshot, tier: MembershipTier, expiresAt: Date | null) => Promise - load?: (guildId: string) => Promise<{ snapshot: QueueSnapshot; expiresAt: Date | null } | null> - purge?: (now?: Date) => Promise + fetchTier?: (_guildId: string) => Promise + persist?: (_snapshot: QueueSnapshot, _tier: MembershipTier, _expiresAt: Date | null) => Promise + load?: (_guildId: string) => Promise<{ snapshot: QueueSnapshot; expiresAt: Date | null } | null> + purge?: (_now?: Date) => Promise now?: () => number } diff --git a/frontend/lib/server-settings-sync.ts b/frontend/lib/server-settings-sync.ts index 4c92719..512c36a 100644 --- a/frontend/lib/server-settings-sync.ts +++ b/frontend/lib/server-settings-sync.ts @@ -2,6 +2,7 @@ import type { MembershipTier } from "./memberships" import type { ServerFeatureSettings } from "./server-settings" import { getApiKeySecret } from "./api-keys" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" const getInternalBaseUrl = () => process.env.NEXT_PUBLIC_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000") @@ -28,7 +29,7 @@ const postWithAuth = async (path: string, body: Record): Promis }) return true } catch (error) { - console.error("[VectoBeat] Broadcast request failed:", error) + logError("Broadcast request failed", error) return false } } diff --git a/frontend/lib/socket-server.ts b/frontend/lib/socket-server.ts index 1cf61d0..d309000 100644 --- a/frontend/lib/socket-server.ts +++ b/frontend/lib/socket-server.ts @@ -1,6 +1,7 @@ import type { NextApiResponse } from "next" import { Server as IOServer } from "socket.io" import { getAllMetrics } from "@/lib/metrics" +import { logError } from "./utils/error-handling" type SocketMeta = { metricsInterval?: NodeJS.Timeout | null @@ -78,7 +79,7 @@ export const ensureSocketServer = async ( const payload = await getAllMetrics() io.emit("stats:update", payload) } catch (error) { - console.error("[VectoBeat] Failed to broadcast analytics:", error) + logError("Failed to broadcast analytics", error) } } diff --git a/frontend/lib/stripe-customers.ts b/frontend/lib/stripe-customers.ts index 0160789..272152f 100644 --- a/frontend/lib/stripe-customers.ts +++ b/frontend/lib/stripe-customers.ts @@ -1,6 +1,7 @@ import type Stripe from "stripe" import { stripe } from "./stripe" import { getUserContact, upsertUserContact } from "./db" +import { logError } from "./utils/error-handling" type EnsureStripeCustomerParams = { discordId: string @@ -54,7 +55,7 @@ export const ensureStripeCustomerForUser = async (params: EnsureStripeCustomerPa return customer.id } catch (error) { - console.error("[VectoBeat] Failed to ensure Stripe customer:", error) + logError("Failed to ensure Stripe customer", error) return params.contact?.stripeCustomerId ?? null } } diff --git a/frontend/lib/telemetry-webhooks.ts b/frontend/lib/telemetry-webhooks.ts index 528e92e..ce2783e 100644 --- a/frontend/lib/telemetry-webhooks.ts +++ b/frontend/lib/telemetry-webhooks.ts @@ -2,6 +2,7 @@ import { createHmac } from "node:crypto" import { getServerSettings } from "@/lib/db" import { apiClient } from "./api-client" +import { logError } from "./utils/error-handling" type TelemetryEnvelope = { ts: number @@ -85,7 +86,7 @@ export const deliverTelemetryWebhook = async (params: { }) return { delivered: true, status: 200, reason: "OK" } } catch (error) { - console.error("[VectoBeat] Telemetry webhook delivery failed:", error) + logError("Telemetry webhook delivery failed", error) return { delivered: false, reason: "network_error" } } } diff --git a/frontend/lib/two-factor.ts b/frontend/lib/two-factor.ts index b578b25..1bbeb43 100644 --- a/frontend/lib/two-factor.ts +++ b/frontend/lib/two-factor.ts @@ -1,4 +1,5 @@ import { authenticator } from "otplib" +import { logError } from "./utils/error-handling" authenticator.options = { step: 30, @@ -15,7 +16,7 @@ export const verifyTwoFactorToken = (secret: string, token: string) => { try { return authenticator.check(token, secret) } catch (error) { - console.error("[VectoBeat] 2FA verification failed:", error) + logError("2FA verification failed", error) return false } } diff --git a/frontend/lib/utils/error-handling.ts b/frontend/lib/utils/error-handling.ts index 17123f9..1791dfe 100644 --- a/frontend/lib/utils/error-handling.ts +++ b/frontend/lib/utils/error-handling.ts @@ -2,9 +2,11 @@ import { handlePrismaError } from "../prisma" export const logError = (message: string, error: unknown) => { handlePrismaError(error) + // eslint-disable-next-line no-console console.error("[ERROR]", message, error) } export const logSecurityError = (message: string, error?: unknown) => { + // eslint-disable-next-line no-console console.error("[SECURITY]", message, error) } diff --git a/frontend/lib/utils/normalization.ts b/frontend/lib/utils/normalization.ts index f60cfc4..d473643 100644 --- a/frontend/lib/utils/normalization.ts +++ b/frontend/lib/utils/normalization.ts @@ -28,14 +28,14 @@ export const sanitizeHandle = (input: string) => { .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() - + let result = '' let prevWasDash = false - + for (let i = 0; i < normalized.length; i++) { const char = normalized[i] const code = char.charCodeAt(0) - + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { result += char prevWasDash = false @@ -44,17 +44,17 @@ export const sanitizeHandle = (input: string) => { prevWasDash = true } } - + let start = 0 while (start < result.length && result[start] === '-') { start++ } - + let end = result.length - 1 while (end >= start && result[end] === '-') { end-- } - + return result.substring(start, end + 1).slice(0, 32) } @@ -83,16 +83,16 @@ export const normalizeSlug = (slug: string) => slug.trim().toLowerCase() export const normalizePath = (path: string, maxLength = 191) => path.slice(0, maxLength) -export const normalizeReferrer = (referrer?: string | null, maxLength = 190) => +export const normalizeReferrer = (referrer?: string | null, maxLength = 190) => referrer ? referrer.slice(0, maxLength) : null -export const normalizeStringWithLength = (value: string, maxLength: number) => +export const normalizeStringWithLength = (value: string, maxLength: number) => value.slice(0, maxLength) -export const normalizeOptionalString = (value?: string | null, maxLength?: number) => +export const normalizeOptionalString = (value?: string | null, maxLength?: number) => value ? (maxLength ? value.slice(0, maxLength) : value) : null -export const normalizeStringArray = (items: string[] | null | undefined, normalizer: (item: string) => string) => { +export const normalizeStringArray = (items: string[] | null | undefined, normalizer: (_: string) => string) => { if (!items || !Array.isArray(items)) return [] return Array.from(new Set(items.map(normalizer).filter(Boolean))) } diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 7e9c31f..24cdeaf 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -44,7 +44,26 @@ const nextConfig = { // ignoreBuildErrors: true, // Removed for type safety }, images: { - // unoptimized: true, // Removed for performance + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.discordapp.com", + port: "", + pathname: "/avatars/**", + }, + { + protocol: "https", + hostname: "cdn.discordapp.com", + port: "", + pathname: "/embed/**", + }, + { + protocol: "https", + hostname: "images.discordapp.net", + port: "", + pathname: "/**", + }, + ], }, experimental: { externalDir: true, diff --git a/frontend/package.json b/frontend/package.json index 54e45e6..9c93e8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,6 @@ "type-check": "tsc --noEmit", "test": "node --test -r ./tests/register-ts.js tests/*.test.ts", "test:report": "node --test --test-reporter=./tests/reporter.mjs -r ./tests/register-ts.js tests/*.test.ts", - "test:new": "node --test -r ./tests/register-ts.js tests/test-oauth-pkce.test.ts tests/test-sanitize-slug.test.ts tests/test-api-auth.test.ts tests/test-api-keys.test.ts tests/test-queue-sync-route.test.ts", "test:server-settings": "node --test -r ./tests/register-ts.js ./tests/server-settings-smoke.test.ts ./tests/membership-tier-normalize.test.ts ./tests/plan-gate-free.test.ts ./tests/provision-defaults.test.ts ./tests/external-queue-access.test.ts ./tests/plan-upgrade-regressions.test.ts ./tests/control-panel-auth.test.ts ./tests/control-panel-server-settings-auth.test.ts ./tests/account-settings-auth.test.ts ./tests/dashboard-subscriptions-auth.test.ts ./tests/concierge-auth.test.ts ./tests/bot-concierge-api.test.ts ./tests/queue-sync-store.test.ts ./tests/dashboard-analytics-queue.test.ts ./tests/queue-sync-concurrency.test.ts ./tests/verify-request-for-user.test.ts ./tests/api-tokens-actor.test.ts ./tests/high-risk-api-integration.test.ts ./tests/bot-contracts.test.ts ./tests/load-failover-simulations.test.ts ./tests/ci-safety.test.ts", "db:generate": "prisma generate", "db:push": "prisma db push", diff --git a/frontend/pages/api/server-settings-broadcast.ts b/frontend/pages/api/server-settings-broadcast.ts index 0940aa6..0f2472c 100644 --- a/frontend/pages/api/server-settings-broadcast.ts +++ b/frontend/pages/api/server-settings-broadcast.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next" import { ensureSocketServer } from "@/lib/socket-server" -import { getApiKeySecret, getApiKeySecrets } from "@/lib/api-keys" +import { getApiKeySecrets } from "@/lib/api-keys" +import { logError } from "@/lib/utils/error-handling" const AUTH_TOKEN_TYPES = ["server_settings", "status_events"] @@ -35,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) io.emit("server-settings:update", { guildId, settings, tier }) return res.status(200).json({ ok: true }) } catch (error) { - console.error("[VectoBeat] Failed to broadcast server settings:", error) + logError("Failed to broadcast server settings", error) return res.status(500).json({ error: "broadcast_failed" }) } } diff --git a/frontend/pages/api/socket.ts b/frontend/pages/api/socket.ts index cd3f05a..285d9a0 100644 --- a/frontend/pages/api/socket.ts +++ b/frontend/pages/api/socket.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next" import { ensureSocketServer } from "@/lib/socket-server" +import { logError } from "@/lib/utils/error-handling" export const config = { api: { @@ -12,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await ensureSocketServer(res, { withMetrics: true }) res.end() } catch (error) { - console.error("[VectoBeat] Socket initialisation failed:", error) + logError("Socket initialisation failed", error) res.status(500).end() } } diff --git a/frontend/tests/api-tokens-actor.test.ts b/frontend/tests/api-tokens-actor.test.ts index 1a13454..0f0acb2 100644 --- a/frontend/tests/api-tokens-actor.test.ts +++ b/frontend/tests/api-tokens-actor.test.ts @@ -2,7 +2,6 @@ import test from "node:test" import assert from "node:assert/strict" import { NextRequest } from "next/server" import { createApiTokenHandlers } from "@/app/api/control-panel/api-tokens/route" -import { type GuildAccessResult } from "@/lib/control-panel-auth" let events: any[] = [] let settingsStore: Record = {} diff --git a/frontend/tests/env-check.test.js b/frontend/tests/env-check.test.js index 1174ff1..12726b5 100644 --- a/frontend/tests/env-check.test.js +++ b/frontend/tests/env-check.test.js @@ -1,7 +1,5 @@ const test = require('node:test'); test('check env', () => { - console.log('NODE_ENV:', process.env.NODE_ENV); - console.log('SKIP_API_AUTH:', process.env.SKIP_API_AUTH); - console.log('DISABLE_API_AUTH:', process.env.DISABLE_API_AUTH); + // Env check logging removed for lint compliance }); diff --git a/frontend/tests/export-pdf.test.ts b/frontend/tests/export-pdf.test.ts index c198534..fa099d2 100644 --- a/frontend/tests/export-pdf.test.ts +++ b/frontend/tests/export-pdf.test.ts @@ -2,7 +2,6 @@ import { test } from "node:test" import assert from "node:assert" import { PDFDocument } from "pdf-lib" import { PdfGenerator } from "@/lib/pdf-generator" -import path from "path" // Mock Data const mockUserData = { @@ -71,28 +70,28 @@ const mockUserData = { ] } -test("PdfGenerator creates a valid PDF with branding", async (t) => { +test("PdfGenerator creates a valid PDF with branding", async () => { const doc = await PDFDocument.create() // We point to a non-existent logo to test fallback or try to point to real one if we know path - const generator = new PdfGenerator(doc, "public/placeholder-logo.png") - + const generator = new PdfGenerator(doc, "public/placeholder-logo.png") + await generator.init() // Simulate Route Logic generator.drawSectionTitle("User Profile") generator.drawKeyValue("Username", mockUserData.profile.username) - + // New Contact Logic generator.drawSectionTitle("Contact Information") - + // @ts-ignore - simulating the route logic which handles potential nulls const email = mockUserData.contact?.email ?? mockUserData.profile?.email ?? "-" // @ts-ignore const phone = mockUserData.contact?.phone ?? mockUserData.profile?.phone ?? "-" // @ts-ignore - const stripeId = mockUserData.contact?.stripeCustomerId ?? - mockUserData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ?? - "-" + const stripeId = mockUserData.contact?.stripeCustomerId ?? + mockUserData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ?? + "-" generator.drawKeyValue("Email", email) generator.drawKeyValue("Phone", phone) @@ -105,33 +104,33 @@ test("PdfGenerator creates a valid PDF with branding", async (t) => { // Password History if (mockUserData.passwordHistory && mockUserData.passwordHistory.length > 0) { - generator.drawSectionTitle("Password History") - const rows = mockUserData.passwordHistory.map((h: any) => [ - h.createdAt.toISOString(), - h.password - ]) - generator.drawTable(["Date", "Password"], rows, [150, 300]) + generator.drawSectionTitle("Password History") + const rows = mockUserData.passwordHistory.map((h: any) => [ + h.createdAt.toISOString(), + h.password + ]) + generator.drawTable(["Date", "Password"], rows, [150, 300]) } // Check Table Generation if (mockUserData.linkedAccounts.length > 0) { - generator.drawSectionTitle("Linked Accounts") - const rows = mockUserData.linkedAccounts.map(acc => [ - acc.provider, - acc.handle, - new Date().toISOString() - ]) - generator.drawTable(["Provider", "Handle", "Linked Date"], rows, [100, 250, 140]) + generator.drawSectionTitle("Linked Accounts") + const rows = mockUserData.linkedAccounts.map(acc => [ + acc.provider, + acc.handle, + new Date().toISOString() + ]) + generator.drawTable(["Provider", "Handle", "Linked Date"], rows, [100, 250, 140]) } // Legal Page generator.addLegalPage() const pdfBytes = await doc.save() - + assert.ok(pdfBytes instanceof Uint8Array, "Should return Uint8Array") assert.ok(pdfBytes.length > 0, "PDF should not be empty") - + // Basic PDF Header Check const header = Buffer.from(pdfBytes.subarray(0, 5)).toString('utf-8') assert.strictEqual(header, "%PDF-", "Should be a valid PDF file") diff --git a/frontend/tests/test-oauth-pkce.test.ts b/frontend/tests/test-oauth-pkce.test.ts index 465fb21..9249a23 100644 --- a/frontend/tests/test-oauth-pkce.test.ts +++ b/frontend/tests/test-oauth-pkce.test.ts @@ -20,7 +20,7 @@ const generateCodeChallenge = (verifier: string): string => // ─── Tests ───────────────────────────────────────────────────────────────────── -const runEncodingCheck = (name: string, checkFn: (encoded: string) => void) => { +const runEncodingCheck = (name: string, checkFn: (_val: string) => void) => { test(`base64UrlEncode – ${name}`, () => { for (let i = 0; i < 200; i++) { checkFn(base64UrlEncode(crypto.randomBytes(64))) @@ -33,7 +33,7 @@ runEncodingCheck("no raw / characters", (encoded) => assert.ok(!encoded.includes runEncodingCheck("no padding = characters", (encoded) => assert.ok(!encoded.includes("="), `Encoded string contains '=': ${encoded}`)) runEncodingCheck("only valid base64url charset", (encoded) => assert.match(encoded, /^[A-Za-z0-9\-_]+$/, `Invalid chars in: ${encoded}`)) -const runVerifierCheck = (name: string, checkFn: (verifier: string) => void) => { +const runVerifierCheck = (name: string, checkFn: (_val: string) => void) => { test(`generateCodeVerifier – ${name}`, () => { for (let i = 0; i < 20; i++) { checkFn(generateCodeVerifier()) diff --git a/frontend/tests/verify-request-for-user.test.ts b/frontend/tests/verify-request-for-user.test.ts index da3e476..304b33d 100644 --- a/frontend/tests/verify-request-for-user.test.ts +++ b/frontend/tests/verify-request-for-user.test.ts @@ -1,11 +1,6 @@ import test from "node:test" import assert from "node:assert/strict" -console.log('TEST FILE ENV CHECK:', { - NODE_ENV: process.env.NODE_ENV, - SKIP_API_AUTH: process.env.SKIP_API_AUTH -}); - import { NextRequest } from "next/server" import { verifyRequestForUser } from "@/lib/auth" diff --git a/frontend/types/next.d.ts b/frontend/types/next.d.ts index e230acb..e831f34 100644 --- a/frontend/types/next.d.ts +++ b/frontend/types/next.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import type { Server as HTTPServer } from "http" import type { Server as IOServer } from "socket.io" @@ -21,4 +22,4 @@ declare module "net" { } } -export {} +export { }