Compare commits

..

5 Commits

11 changed files with 850 additions and 357 deletions

View File

@@ -66,7 +66,9 @@ You will need these env variables from the application settings:
EVE_SSO_CLIENT_ID=Client ID
EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
WEBHOOK_URL=Discord webhook URL for notifications (optional)
# Webhook Configuration (optional)
WEBHOOK_URL=Discord webhook URL for notifications
```
## Run locally
@@ -85,11 +87,44 @@ The application supports webhook notifications for various PI events:
- **Launch pad capacity warnings**: Notify when launch pads are about to be full (configurable percentage)
- **Launch pad full**: Notify when launch pads are at 100% capacity
### Webhook Configuration
To enable webhook notifications:
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL
2. Enable webhook notifications in the Settings dialog
3. Configure warning thresholds for extractors, storage, and launch pads
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL or other webhook endpoint
2. Open the Settings dialog in the application and enable webhook notifications
3. Configure thresholds in the Settings dialog:
- **Extractor Expiry Warning**: How early to warn about extractor expiry (ISO 8601 duration format, default: P12H = 12 hours)
- **Storage Warning Threshold**: Storage fill percentage for warnings (default: 85%)
- **Storage Critical Threshold**: Storage fill percentage for critical alerts (default: 100%)
### Webhook Behavior
- **Automatic checks**: Runs every minute to check extractor expiry and storage levels
- **Data fetch checks**: Also checks when planet data is refreshed
- **Duplicate prevention**: Won't send the same alert repeatedly within cooldown periods
- **State reset**: Extractor alerts reset when extractors are restarted with fresh cycles
### Webhook Payload Format
The webhook sends JSON payloads with this structure:
```json
{
"type": "extractor_expiring|extractor_expired|storage_almost_full|storage_full|launchpad_almost_full|launchpad_full",
"message": "Human readable message",
"characterName": "Character Name",
"planetName": "Planet Name",
"details": {
"extractorType": "Product Type",
"hoursRemaining": 1.5,
"storageUsed": 8500,
"storageCapacity": 10000,
"fillPercentage": 85.0
},
"timestamp": "2024-01-01T12:00:00.000Z"
}
```
The webhook URL is kept secure in environment variables and not stored in the browser.

View File

@@ -1,27 +1,28 @@
import {
ColorContext,
ColorSelectionType,
SessionContext,
ColorContext,
ColorSelectionType,
SessionContext,
} from "@/app/context/Context";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tooltip,
Typography,
TextField,
Box,
FormControlLabel,
Checkbox,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tooltip,
Typography,
TextField,
Box,
FormControlLabel,
Checkbox,
Divider,
} from "@mui/material";
import { ColorResult, CompactPicker } from "react-color";
import React, { useState, useContext } from "react";
export const SettingsButton = () => {
const { colors, setColors } = useContext(ColorContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig } = useContext(SessionContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig, webhookServerEnabled } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -52,40 +53,53 @@ export const SettingsButton = () => {
};
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebhookConfig({
...webhookConfig,
enabled: event.target.checked
});
if (!webhookServerEnabled && event.target.checked) {
// Don't allow enabling if server doesn't support it
return;
}
setWebhookConfig(prev => ({ ...prev, enabled: event.target.checked }));
};
const handleExpiryThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value }));
};
const handleExtractorWarningHoursChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleStorageWarningChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 168) { // Max 1 week
setWebhookConfig({
...webhookConfig,
extractorWarningHours: value
});
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value }));
}
};
const handleStorageWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleStorageCriticalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig({
...webhookConfig,
storageWarningPercent: value
});
setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value }));
}
};
const handleLaunchpadWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig({
...webhookConfig,
launchpadWarningPercent: value
const handleRefreshStats = () => {
// Trigger a manual refresh of character data
window.location.reload();
};
const handleRefreshESI = async () => {
try {
const response = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true })
});
if (response.ok) {
console.log("✅ ESI data refreshed successfully");
// Optionally reload the page to show fresh data
window.location.reload();
} else {
console.error("❌ Failed to refresh ESI data");
}
} catch (error) {
console.error("❌ Error refreshing ESI data:", error);
}
};
@@ -131,57 +145,98 @@ export const SettingsButton = () => {
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Data Refresh</Typography>
<Button
onClick={handleRefreshESI}
variant="contained"
fullWidth
sx={{ mt: 1 }}
>
Refresh ESI Data
</Button>
<Button
onClick={handleRefreshStats}
variant="outlined"
fullWidth
sx={{ mt: 1 }}
>
Refresh All Data
</Button>
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Webhook Notifications</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Configure alerts for extractor expiry and storage capacity warnings.
{!webhookServerEnabled && " (Server webhook support not available - WEBHOOK_URL not configured)"}
</Typography>
<FormControlLabel
control={
<Checkbox
checked={webhookConfig.enabled}
checked={webhookConfig.enabled && webhookServerEnabled}
onChange={handleWebhookEnabledChange}
disabled={!webhookServerEnabled}
/>
}
label="Enable webhook notifications"
/>
{webhookConfig.enabled && (
{webhookConfig.enabled && webhookServerEnabled && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Webhook URL is configured via the WEBHOOK_URL environment variable.
</Typography>
<TextField
type="number"
label="Extractor Warning Hours"
value={webhookConfig.extractorWarningHours}
onChange={handleExtractorWarningHoursChange}
label="Extractor Expiry Warning"
value={webhookConfig.expiryThreshold}
onChange={handleExpiryThresholdChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 168 }}
helperText="Hours before extractor expiry to send warning (0-168)"
error={webhookConfig.extractorWarningHours < 0 || webhookConfig.extractorWarningHours > 168}
helperText="ISO 8601 duration format (e.g., P12H for 12 hours, P1D for 1 day)"
/>
<TextField
type="number"
label="Storage Warning %"
value={webhookConfig.storageWarningPercent}
onChange={handleStorageWarningPercentChange}
label="Storage Warning Threshold (%)"
value={webhookConfig.storageWarningThreshold}
onChange={handleStorageWarningChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Storage capacity percentage to trigger warning (0-100)"
error={webhookConfig.storageWarningPercent < 0 || webhookConfig.storageWarningPercent > 100}
helperText="Alert when storage reaches this percentage (0-100)"
/>
<TextField
type="number"
label="Launchpad Warning %"
value={webhookConfig.launchpadWarningPercent}
onChange={handleLaunchpadWarningPercentChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Launchpad capacity percentage to trigger warning (0-100)"
error={webhookConfig.launchpadWarningPercent < 0 || webhookConfig.launchpadWarningPercent > 100}
/>
</>
)}
<TextField
type="number"
label="Storage Critical Threshold (%)"
value={webhookConfig.storageCriticalThreshold}
onChange={handleStorageCriticalChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Alert when storage is completely full (usually 100)"
/>
<Button
onClick={() => {
const { notificationService } = require("@/utils/notificationService");
const characters = JSON.parse(localStorage.getItem("characters") || "[]");
notificationService.checkAndNotify(characters, webhookConfig);
}}
variant="outlined"
sx={{ mt: 2 }}
fullWidth
>
Test Webhook
</Button>
</>
)}
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Colors</Typography>
</Box>
{Object.keys(colors).map((key) => {
return (

View File

@@ -1,5 +1,6 @@
import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate, PlanetConfig, WebhookConfig } from "@/types";
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { WebhookConfig } from "@/types/webhook";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
@@ -42,7 +43,8 @@ export const SessionContext = createContext<{
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
webhookConfig: WebhookConfig;
setWebhookConfig: (config: WebhookConfig) => void;
setWebhookConfig: Dispatch<SetStateAction<WebhookConfig>>;
webhookServerEnabled: boolean;
}>({
sessionReady: false,
refreshSession: () => {},
@@ -74,11 +76,12 @@ export const SessionContext = createContext<{
setShowProductIcons: () => {},
webhookConfig: {
enabled: false,
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
},
setWebhookConfig: () => {},
webhookServerEnabled: false,
});
export type ColorSelectionType = {

View File

@@ -4,7 +4,7 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { memo, useCallback, useEffect, useState, Suspense } from "react";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo, WebhookConfig } from "../types";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types";
import { MainGrid } from "./components/MainGrid";
import { refreshToken } from "@/esi-sso";
import {
@@ -18,7 +18,10 @@ import { useSearchParams } from "next/navigation";
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
import { PlanetConfig } from "@/types";
import { notificationService } from "@/utils/notificationService";
import { runWebhookChecks } from "@/utils/webhookService";
import { WebhookConfig } from "@/types/webhook";
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
import { planetCalculations } from "@/planets";
// Add batch processing utility
const processInBatches = async <T, R>(
@@ -48,12 +51,21 @@ const Home = () => {
const [balanceThreshold, setBalanceThreshold] = useState(1000);
const [showProductIcons, setShowProductIcons] = useState(false);
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>({
enabled: false,
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem("webhookConfig");
if (stored) {
return JSON.parse(stored);
}
}
return {
enabled: false,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
};
});
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false);
@@ -116,6 +128,7 @@ const Home = () => {
};
const initializeCharacters = useCallback((): AccessToken[] => {
if (typeof window === 'undefined') return [];
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
@@ -140,6 +153,27 @@ const Home = () => {
infoUniverse: await getPlanetUniverse(p),
})
);
// Run webhook checks for each planet if webhooks are enabled
if (webhookConfig.enabled) {
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
for (const planet of planetsWithInfo) {
try {
const calculations = planetCalculations(planet);
await runWebhookChecks(
c,
planet,
calculations.extractors,
webhookConfig
);
} catch (error) {
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
}
}
} else {
console.log('🔕 Webhooks disabled, skipping checks');
}
return {
...c,
planets: planetsWithInfo,
@@ -147,7 +181,9 @@ const Home = () => {
});
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
localStorage.setItem("characters", JSON.stringify(characters));
if (typeof window !== 'undefined') {
localStorage.setItem("characters", JSON.stringify(characters));
}
return characters;
};
@@ -209,76 +245,123 @@ const Home = () => {
};
useEffect(() => {
const storedCompactMode = localStorage.getItem("compactMode");
if (!storedCompactMode) return;
storedCompactMode === "true" ? setCompactMode(true) : false;
}, []);
useEffect(() => {
const storedColors = localStorage.getItem("colors");
if (!storedColors) return;
setColors(JSON.parse(storedColors));
}, []);
useEffect(() => {
const storedAlertMode = localStorage.getItem("alertMode");
if (!storedAlertMode) return;
setAlertMode(JSON.parse(storedAlertMode));
}, []);
useEffect(() => {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
if (typeof window !== 'undefined') {
const storedCompactMode = localStorage.getItem("compactMode");
if (storedCompactMode) {
setCompactMode(storedCompactMode === "true");
}
}
}, []);
useEffect(() => {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
if (typeof window !== 'undefined') {
const storedColors = localStorage.getItem("colors");
if (storedColors) {
setColors(JSON.parse(storedColors));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedAlertMode = localStorage.getItem("alertMode");
if (storedAlertMode) {
setAlertMode(JSON.parse(storedAlertMode));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
}
}, [balanceThreshold]);
useEffect(() => {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
if (typeof window !== 'undefined') {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
}
}, [compactMode]);
useEffect(() => {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
if (typeof window !== 'undefined') {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
}
}, [alertMode]);
useEffect(() => {
localStorage.setItem("colors", JSON.stringify(colors));
if (typeof window !== 'undefined') {
localStorage.setItem("colors", JSON.stringify(colors));
}
}, [colors]);
useEffect(() => {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
if (typeof window !== 'undefined') {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
}
}
}, []);
useEffect(() => {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
if (typeof window !== 'undefined') {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}
}, [extractionTimeMode]);
// Load webhook config from localStorage after hydration
useEffect(() => {
const storedWebhookConfig = localStorage.getItem("webhookConfig");
if (storedWebhookConfig) {
setWebhookConfig(JSON.parse(storedWebhookConfig));
if (typeof window !== 'undefined') {
const savedWebhookConfig = localStorage.getItem("webhookConfig");
if (savedWebhookConfig) {
try {
const config = JSON.parse(savedWebhookConfig);
setWebhookConfig(prev => ({ ...prev, ...config }));
} catch (error) {
console.warn('Failed to parse webhook config from localStorage:', error);
}
}
}
}, []);
// Save webhook config to localStorage
useEffect(() => {
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
if (typeof window !== 'undefined') {
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
}
}, [webhookConfig]);
useEffect(() => {
console.log('🔧 Initializing app...');
fetch("api/env")
.then((r) => r.json())
.then((env) => {
console.log('🌐 Environment loaded:', env);
setEnvironment({
EVE_SSO_CLIENT_ID: env.EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL: env.EVE_SSO_CALLBACK_URL,
});
setWebhookServerEnabled(env.WEBHOOK_ENABLED || false);
console.log('🔔 Webhook server enabled:', env.WEBHOOK_ENABLED);
// Only allow enabling webhooks if server supports it
if (!env.WEBHOOK_ENABLED && webhookConfig.enabled) {
console.log('⚠️ Disabling webhooks - server not configured');
setWebhookConfig(prev => ({
...prev,
enabled: false
}));
}
})
.then(initializeCharacters)
.then(refreshSession)
@@ -302,14 +385,53 @@ const Home = () => {
refreshSession(characters)
.then(saveCharacters)
.then(initializeCharacterPlanets)
.then((updatedCharacters) => {
setCharacters(updatedCharacters);
// Check for notifications after updating characters
notificationService.checkAndNotify(updatedCharacters, webhookConfig);
});
.then(setCharacters);
}, ESI_CACHE_TIME_MS);
return () => clearInterval(interval);
}, [webhookConfig]);
});
// Regular webhook checks (every minute)
useEffect(() => {
if (!webhookConfig.enabled) {
console.log('🔕 Webhooks disabled, skipping regular checks');
return;
}
console.log('⏰ Setting up regular webhook checks (every minute)');
const runRegularWebhookChecks = async () => {
console.log('🔄 Running regular webhook checks...');
const currentCharacters = initializeCharacters();
for (const character of currentCharacters) {
if (character.needsLogin || !character.planets) continue;
for (const planet of character.planets) {
try {
const calculations = planetCalculations(planet);
await runWebhookChecks(
character,
planet,
calculations.extractors,
webhookConfig
);
} catch (error) {
console.warn('Regular webhook check failed for planet:', planet.infoUniverse.name, error);
}
}
}
// Cleanup old webhook states
cleanupOldWebhookStates();
};
// Run immediately
runRegularWebhookChecks();
// Set up interval for every minute
const webhookInterval = setInterval(runRegularWebhookChecks, 60000);
return () => clearInterval(webhookInterval);
}, [webhookConfig.enabled]);
return (
<SessionContext.Provider
@@ -336,6 +458,7 @@ const Home = () => {
setShowProductIcons,
webhookConfig,
setWebhookConfig,
webhookServerEnabled,
}}
>
<CharacterContext.Provider

View File

@@ -10,6 +10,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
const WEBHOOK_ENABLED = !!process.env.WEBHOOK_URL;
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
logger.error({
@@ -27,11 +28,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
event: 'env_request_success',
vars: {
hasCallbackUrl: true,
hasClientId: true
hasClientId: true,
webhookEnabled: WEBHOOK_ENABLED
}
});
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
return res.json({
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL,
WEBHOOK_ENABLED
});
} catch (e) {
logger.error({
event: 'env_request_failed',

View File

@@ -1,67 +1,62 @@
import { NextApiRequest, NextApiResponse } from "next";
import logger from "@/utils/logger";
import type { NextApiRequest, NextApiResponse } from 'next';
import { WebhookPayload } from '@/types/webhook';
interface WebhookPayload {
content: string;
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
const { content }: { content: string } = req.body;
if (!content) {
return res.status(400).json({ error: "Content is required" });
}
const webhookUrl = process.env.WEBHOOK_URL;
if (!webhookUrl) {
return res.status(500).json({ error: "Webhook URL not configured" });
}
const payload: WebhookPayload = { content };
logger.info({
event: 'webhook_send_start',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'), // Mask the webhook token
contentLength: content.length
});
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Webhook request failed with status ${response.status}`);
}
logger.info({
event: 'webhook_send_success',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'),
status: response.status
});
return res.json({ success: true });
} catch (error) {
logger.error({
event: 'webhook_send_failed',
error: error instanceof Error ? error.message : String(error),
body: req.body
});
return res.status(500).json({ error: "Failed to send webhook" });
}
} else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
};
export default handler;
try {
const payload: WebhookPayload = req.body;
// Validate payload
if (!payload.type || !payload.message || !payload.characterName || !payload.planetName) {
return res.status(400).json({ error: 'Invalid payload' });
}
const webhookUrl = process.env.WEBHOOK_URL;
if (!webhookUrl) {
// If no external webhook URL is configured, just log the payload for testing
console.log('🔔 Webhook payload (no external URL configured):', JSON.stringify(payload, null, 2));
return res.status(200).json({
success: true,
message: 'Webhook logged to console (no external URL configured)',
payload
});
}
// Send webhook to external URL
console.log('🔔 Sending webhook to external URL:', webhookUrl);
// Use simple format with all the details
const webhookPayload = {
content: `${payload.message}\n**Character:** ${payload.characterName}\n**Planet:** ${payload.planetName}\n**Time:** ${new Date(payload.timestamp).toLocaleString()}`
};
console.log('📤 Sending payload:', JSON.stringify(webhookPayload, null, 2));
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(webhookPayload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Webhook failed:', response.status, response.statusText, errorText);
throw new Error(`Webhook failed with status ${response.status}: ${errorText}`);
}
console.log('✅ Webhook sent successfully to external URL');
res.status(200).json({ success: true, message: 'Webhook sent to external URL' });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
}
}

View File

@@ -78,6 +78,7 @@ export interface CharacterUpdate {
export interface Env {
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
WEBHOOK_ENABLED?: boolean;
}
export interface EvePraisalResult {
@@ -123,19 +124,3 @@ export interface PlanetConfig {
planetId: number;
excludeFromTotals: boolean;
}
export interface WebhookConfig {
enabled: boolean;
extractorWarningHours: number;
storageWarningPercent: number;
launchpadWarningPercent: number;
}
export interface NotificationState {
extractorExpired: Set<string>;
extractorWarning: Set<string>;
storageFull: Set<string>;
storageWarning: Set<string>;
launchpadFull: Set<string>;
launchpadWarning: Set<string>;
}

21
src/types/webhook.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface WebhookConfig {
enabled: boolean;
expiryThreshold: string; // ISO 8601 duration format
storageWarningThreshold: number; // percentage
storageCriticalThreshold: number; // percentage
}
export interface WebhookPayload {
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full';
message: string;
characterName: string;
planetName: string;
details?: {
extractorType?: string;
hoursRemaining?: number;
storageUsed?: number;
storageCapacity?: number;
fillPercentage?: number;
};
timestamp: string;
}

View File

@@ -1,179 +1,101 @@
import { DateTime } from "luxon";
import { AccessToken, WebhookConfig, NotificationState, PlanetCalculations, PlanetWithInfo } from "@/types";
import { AccessToken, WebhookConfig } from "@/types";
import { planetCalculations } from "@/planets";
import { LAUNCHPAD_IDS } from "@/const";
export class NotificationService {
private notificationState: NotificationState = {
extractorExpired: new Set(),
extractorWarning: new Set(),
storageFull: new Set(),
storageWarning: new Set(),
launchpadFull: new Set(),
launchpadWarning: new Set(),
};
private sendWebhook = async (content: string): Promise<void> => {
try {
const response = await fetch("/api/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`Webhook request failed with status ${response.status}`);
}
} catch (error) {
console.error("Failed to send webhook:", error);
}
};
private getPlanetIdentifier = (characterName: string, planetId: number): string => {
return `${characterName}-${planetId}`;
};
private checkExtractorExpiry = (
character: AccessToken,
planet: PlanetWithInfo,
planetCalculations: PlanetCalculations,
webhookConfig: WebhookConfig
): string[] => {
const notifications: string[] = [];
const now = DateTime.now();
const warningTime = now.plus({ hours: webhookConfig.extractorWarningHours });
planetCalculations.extractors.forEach((extractor) => {
if (!extractor.expiry_time) return;
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const planetId = planet.planet_id;
const identifier = this.getPlanetIdentifier(character.character.name, planetId);
// Check if extractor has expired
if (expiryTime <= now) {
if (!this.notificationState.extractorExpired.has(identifier)) {
this.notificationState.extractorExpired.add(identifier);
notifications.push(
`🚨 **EXTRACTOR EXPIRED** - ${character.character.name} Planet ${planetId} - Extractor expired at ${expiryTime.toFormat("yyyy-MM-dd HH:mm")}`
);
}
} else {
// Remove from expired set if it's no longer expired
this.notificationState.extractorExpired.delete(identifier);
}
// Check if extractor is about to expire (warning)
if (expiryTime <= warningTime && expiryTime > now) {
if (!this.notificationState.extractorWarning.has(identifier)) {
this.notificationState.extractorWarning.add(identifier);
const hoursLeft = Math.round(expiryTime.diff(now, "hours").hours);
notifications.push(
`⚠️ **EXTRACTOR WARNING** - ${character.character.name} Planet ${planetId} - Extractor expires in ${hoursLeft} hours (${expiryTime.toFormat("yyyy-MM-dd HH:mm")})`
);
}
} else {
// Remove from warning set if it's no longer in warning period
this.notificationState.extractorWarning.delete(identifier);
}
});
return notifications;
};
private checkStorageCapacity = (
character: AccessToken,
planet: PlanetWithInfo,
planetCalculations: PlanetCalculations,
webhookConfig: WebhookConfig
): string[] => {
const notifications: string[] = [];
planetCalculations.storageInfo.forEach((storage) => {
const planetId = planet.planet_id;
const identifier = this.getPlanetIdentifier(character.character.name, planetId);
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const warningThreshold = isLaunchpad ? webhookConfig.launchpadWarningPercent : webhookConfig.storageWarningPercent;
// Check if storage is full (100%)
if (storage.fillRate >= 100) {
const storageType = isLaunchpad ? "Launchpad" : "Storage";
if (!this.notificationState.storageFull.has(identifier)) {
this.notificationState.storageFull.add(identifier);
notifications.push(
`🚨 **${storageType.toUpperCase()} FULL** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity`
);
}
} else {
this.notificationState.storageFull.delete(identifier);
}
// Check if storage is about to be full (warning threshold)
if (storage.fillRate >= warningThreshold && storage.fillRate < 100) {
const storageType = isLaunchpad ? "Launchpad" : "Storage";
if (!this.notificationState.storageWarning.has(identifier)) {
this.notificationState.storageWarning.add(identifier);
notifications.push(
`⚠️ **${storageType.toUpperCase()} WARNING** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity`
);
}
} else {
this.notificationState.storageWarning.delete(identifier);
}
});
return notifications;
};
private notificationState = new Map<string, boolean>();
private lastWebhookTime = 0;
private readonly WEBHOOK_COOLDOWN = 5000; // 5 seconds between webhooks
public checkAndNotify = async (
characters: AccessToken[],
webhookConfig: WebhookConfig
): Promise<void> => {
if (!webhookConfig.enabled) {
return;
}
if (!webhookConfig.enabled) return;
// Get webhook URL from environment variables
const webhookUrl = process.env.WEBHOOK_URL;
if (!webhookUrl) {
console.warn("Webhook notifications enabled but WEBHOOK_URL environment variable not set");
return;
}
const allNotifications: string[] = [];
const notifications: string[] = [];
const now = DateTime.now();
for (const character of characters) {
for (const planet of character.planets) {
const planetDetails = planetCalculations(planet);
// Check extractor expiry
const extractorNotifications = this.checkExtractorExpiry(character, planet, planetDetails, webhookConfig);
allNotifications.push(...extractorNotifications);
// Check extractors for expiry warnings
for (const extractor of planetDetails.extractors) {
if (extractor.expiry_time) {
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const identifier = `${character.character.name}-${planet.planet_id}-${extractor.pin_id}`;
// Check if already expired
if (expiryTime <= now) {
if (!this.notificationState.get(`${identifier}-expired`)) {
notifications.push(`🚨 EXTRACTOR EXPIRED - ${character.character.name} Planet ${planet.planet_id}`);
this.notificationState.set(`${identifier}-expired`, true);
}
} else {
// Check if expiring soon (within the warning threshold)
const warningThreshold = this.parseDuration(webhookConfig.expiryThreshold || "P12H");
const warningTime = now.plus(warningThreshold);
if (expiryTime <= warningTime && expiryTime > now) {
if (!this.notificationState.get(`${identifier}-warning`)) {
const hoursLeft = Math.round(expiryTime.diff(now, "hours").hours);
notifications.push(`⚠️ EXTRACTOR EXPIRING SOON - ${character.character.name} Planet ${planet.planet_id} (${hoursLeft}h left)`);
this.notificationState.set(`${identifier}-warning`, true);
}
}
}
}
}
// Check storage capacity
const storageNotifications = this.checkStorageCapacity(character, planet, planetDetails, webhookConfig);
allNotifications.push(...storageNotifications);
for (const storage of planetDetails.storageInfo) {
const identifier = `${character.character.name}-${planet.planet_id}-${storage.type_id}`;
if (storage.fillRate >= (webhookConfig.storageCriticalThreshold || 100)) {
if (!this.notificationState.get(`${identifier}-critical`)) {
notifications.push(`🚨 STORAGE CRITICAL - ${character.character.name} Planet ${planet.planet_id} (${storage.fillRate.toFixed(1)}%)`);
this.notificationState.set(`${identifier}-critical`, true);
}
} else if (storage.fillRate >= (webhookConfig.storageWarningThreshold || 85)) {
if (!this.notificationState.get(`${identifier}-warning`)) {
notifications.push(`⚠️ STORAGE WARNING - ${character.character.name} Planet ${planet.planet_id} (${storage.fillRate.toFixed(1)}%)`);
this.notificationState.set(`${identifier}-warning`, true);
}
}
}
}
}
// Send all notifications in a single webhook
if (allNotifications.length > 0) {
const content = allNotifications.join("\n");
await this.sendWebhook(content);
if (notifications.length > 0) {
await this.sendWebhook(notifications.join("\n"));
}
};
public resetNotificationState = (): void => {
this.notificationState = {
extractorExpired: new Set(),
extractorWarning: new Set(),
storageFull: new Set(),
storageWarning: new Set(),
launchpadFull: new Set(),
launchpadWarning: new Set(),
};
private parseDuration(duration: string): any {
// Parse ISO 8601 duration (e.g., "P12H", "P1D", "PT2H30M")
try {
return DateTime.fromISO(`2000-01-01T00:00:00Z`).plus({ [duration.slice(-1) === 'H' ? 'hours' : duration.slice(-1) === 'D' ? 'days' : 'minutes' ]: parseInt(duration.slice(1, -1)) }).diff(DateTime.fromISO(`2000-01-01T00:00:00Z`));
} catch {
// Default to 12 hours if parsing fails
return { hours: 12 };
}
}
private sendWebhook = async (content: string): Promise<void> => {
try {
const response = await fetch("/api/webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status}`);
}
} catch (error) {
console.error("Webhook error:", error);
}
};
}

226
src/utils/webhookService.ts Normal file
View File

@@ -0,0 +1,226 @@
import { DateTime, Duration } from 'luxon';
import { Pin, PlanetWithInfo, AccessToken } from '@/types';
import { StorageInfo } from '@/types/planet';
import { WebhookConfig, WebhookPayload } from '@/types/webhook';
import { STORAGE_IDS, LAUNCHPAD_IDS, PI_TYPES_MAP, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } from '@/const';
import { shouldSendWebhook, markWebhookSent } from './webhookTracker';
const sendWebhook = async (payload: WebhookPayload): Promise<boolean> => {
try {
console.log('🔔 Sending webhook:', payload);
const response = await fetch('/api/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.ok) {
const result = await response.json();
console.log('✅ Webhook sent successfully:', result.message);
} else {
const error = await response.text();
console.log('❌ Webhook failed:', response.status, response.statusText, error);
}
return response.ok;
} catch (error) {
console.error('❌ Failed to send webhook:', error);
return false;
}
};
export const checkExtractorExpiry = async (
character: AccessToken,
planet: PlanetWithInfo,
extractors: Pin[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) return;
if (extractors.length === 0) {
console.log(`📊 No extractors found on ${planet.infoUniverse.name}`);
return;
}
console.log(`⏰ Checking ${extractors.length} extractors on ${planet.infoUniverse.name}`);
const now = DateTime.now();
const expiryThreshold = Duration.fromISO(config.expiryThreshold);
for (const extractor of extractors) {
if (!extractor.expiry_time || !extractor.extractor_details?.product_type_id) continue;
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const timeUntilExpiry = expiryTime.diff(now);
const hoursRemaining = timeUntilExpiry.as('hours');
const productType = PI_TYPES_MAP[extractor.extractor_details.product_type_id];
const extractorType = productType?.name || `Type ${extractor.extractor_details.product_type_id}`;
// Check if extractor has expired
if (expiryTime <= now) {
console.log(`⏰ Extractor expired: ${extractorType} on ${planet.infoUniverse.name}`);
const event = 'done';
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
const payload: WebhookPayload = {
type: 'extractor_expired',
message: `Extractor producing ${extractorType} has expired on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
extractorType,
hoursRemaining: 0
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
}
}
}
// Check if extractor is about to expire
else if (timeUntilExpiry <= expiryThreshold) {
console.log(`⚠️ Extractor expiring soon: ${extractorType} on ${planet.infoUniverse.name} (${hoursRemaining.toFixed(1)}h remaining)`);
const event = 'nearly done';
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
const payload: WebhookPayload = {
type: 'extractor_expiring',
message: `Extractor producing ${extractorType} will expire in ${hoursRemaining.toFixed(1)} hours on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
extractorType,
hoursRemaining: Math.max(0, hoursRemaining)
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
}
}
}
}
};
export const checkStorageCapacity = async (
character: AccessToken,
planet: PlanetWithInfo,
storageInfo: StorageInfo[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) return;
if (storageInfo.length === 0) {
console.log(`📦 No storage facilities found on ${planet.infoUniverse.name}`);
return;
}
console.log(`📦 Checking ${storageInfo.length} storage facilities on ${planet.infoUniverse.name}`);
const now = DateTime.now();
for (const storage of storageInfo) {
const fillPercentage = (storage.used / storage.capacity) * 100;
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const storageTypeName = PI_TYPES_MAP[storage.type_id]?.name || `Storage ${storage.type_id}`;
// Check for critical (100%) storage
if (fillPercentage >= config.storageCriticalThreshold) {
const webhookType = isLaunchpad ? 'launchpad_full' : 'storage_full';
console.log(`🚨 ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
const payload: WebhookPayload = {
type: webhookType,
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
storageUsed: storage.used,
storageCapacity: storage.capacity,
fillPercentage: fillPercentage
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
}
}
}
// Check for warning threshold
else if (fillPercentage >= config.storageWarningThreshold) {
const webhookType = isLaunchpad ? 'launchpad_almost_full' : 'storage_almost_full';
console.log(`⚠️ ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
const payload: WebhookPayload = {
type: webhookType,
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
storageUsed: storage.used,
storageCapacity: storage.capacity,
fillPercentage: fillPercentage
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
}
}
}
}
};
const calculateStorageInfo = (planet: PlanetWithInfo): StorageInfo[] => {
const storageFacilities = planet.info.pins.filter((pin: Pin) =>
STORAGE_IDS().some(storage => storage.type_id === pin.type_id)
);
return storageFacilities.map((storage: Pin): StorageInfo => {
const storageType = PI_TYPES_MAP[storage.type_id]?.name || 'Unknown';
const storageCapacity = STORAGE_CAPACITIES[storage.type_id] || 0;
const totalVolume = (storage.contents || [])
.reduce((sum: number, item) => {
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0;
return sum + (item.amount * volume);
}, 0);
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
return {
type: storageType,
type_id: storage.type_id,
capacity: storageCapacity,
used: totalVolume,
fillRate: fillRate,
value: 0 // We don't need value for webhook checks
};
});
};
export const runWebhookChecks = async (
character: AccessToken,
planet: PlanetWithInfo,
extractors: Pin[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) {
console.log('🔕 Webhooks disabled, skipping checks');
return;
}
console.log(`🔍 Running webhook checks for ${character.character.name} - ${planet.infoUniverse.name}`);
const storageInfo = calculateStorageInfo(planet);
await Promise.all([
checkExtractorExpiry(character, planet, extractors, config),
checkStorageCapacity(character, planet, storageInfo, config)
]);
};

122
src/utils/webhookTracker.ts Normal file
View File

@@ -0,0 +1,122 @@
interface WebhookState {
sent: boolean;
lastSent: number;
}
// Create hash for unique events: character-planet-extractorEndTimestamp-event
export const createWebhookHash = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): string => {
return `${characterId}-${planetId}-${extractorEndTimestamp}-${event}`;
};
// Get webhook state from localStorage
const getWebhookState = (hash: string): WebhookState => {
if (typeof window === 'undefined') return { sent: false, lastSent: 0 };
try {
const stored = localStorage.getItem(`webhook_${hash}`);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.warn('Failed to parse webhook state from localStorage:', error);
}
return { sent: false, lastSent: 0 };
};
// Set webhook state in localStorage
const setWebhookState = (hash: string, state: WebhookState): void => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(`webhook_${hash}`, JSON.stringify(state));
} catch (error) {
console.warn('Failed to save webhook state to localStorage:', error);
}
};
export const shouldSendWebhook = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): boolean => {
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
const state = getWebhookState(hash);
return !state.sent;
};
// Storage webhook tracking with 1-hour cooldown
export const shouldSendStorageWebhook = (
characterId: number,
planetId: number,
storageTypeId: number,
webhookType: string
): boolean => {
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
const state = getWebhookState(hash);
if (!state.sent) {
return true;
}
// Check if 1 hour has passed since last notification
const oneHourAgo = Date.now() - (60 * 60 * 1000);
return state.lastSent < oneHourAgo;
};
export const markStorageWebhookSent = (
characterId: number,
planetId: number,
storageTypeId: number,
webhookType: string
): void => {
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
setWebhookState(hash, { sent: true, lastSent: Date.now() });
};
export const markWebhookSent = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): void => {
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
setWebhookState(hash, { sent: true, lastSent: Date.now() });
};
// Cleanup old webhook states (older than 7 days)
export const cleanupOldWebhookStates = (): void => {
if (typeof window === 'undefined') return;
const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('webhook_')) {
const state = JSON.parse(localStorage.getItem(key) || '{}');
if (state.lastSent && state.lastSent < cutoff) {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
if (keysToRemove.length > 0) {
console.log(`🧹 Cleaned up ${keysToRemove.length} old webhook states`);
}
} catch (error) {
console.warn('Failed to cleanup old webhook states:', error);
}
};