Skip to content

Commit 81fa110

Browse files
authored
Merge pull request #1 from Denver-MeshCore/feature/discord-integration-and-cache-fix
feat: Add Discord network status integration + fix CDN caching
2 parents 880a847 + e5ad24f commit 81fa110

File tree

12 files changed

+868
-13
lines changed

12 files changed

+868
-13
lines changed

netlify.toml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,20 @@
6161
[headers.values]
6262
Cache-Control = "no-store, no-cache, must-revalidate"
6363

64-
# Development/Preview context overrides
64+
# Preview context - clear cache to avoid stale artifacts
6565
[context.deploy-preview]
66-
command = "npm ci && npm run build"
66+
command = "rm -rf .next && npm ci && npm run build"
6767
[context.deploy-preview.environment]
68-
NODE_ENV = "development"
68+
NODE_ENV = "production"
6969

7070
[context.branch-deploy]
71-
command = "npm ci && npm run build"
71+
command = "rm -rf .next && npm ci && npm run build"
7272
[context.branch-deploy.environment]
73-
NODE_ENV = "development"
73+
NODE_ENV = "production"
7474

75-
# Production context
75+
# Production context - clear cache to avoid stale artifacts
7676
[context.production]
77-
command = "npm ci && npm run build"
77+
command = "rm -rf .next && npm ci && npm run build"
7878
[context.production.environment]
7979
NODE_ENV = "production"
8080

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Config } from '@netlify/functions';
2+
3+
/**
4+
* Scheduled function to post network health summary to Discord
5+
* Runs every 6 hours to provide regular status updates
6+
*/
7+
export default async function handler() {
8+
const siteUrl = process.env.URL || process.env.DEPLOY_PRIME_URL;
9+
const webhookSecret = process.env.DISCORD_WEBHOOK_SECRET;
10+
11+
if (!siteUrl || !webhookSecret) {
12+
console.error('Missing URL or DISCORD_WEBHOOK_SECRET environment variables');
13+
return new Response('Configuration error', { status: 500 });
14+
}
15+
16+
console.log(`Sending scheduled Discord update from ${siteUrl}`);
17+
18+
try {
19+
const response = await fetch(`${siteUrl}/api/discord-webhook`, {
20+
method: 'POST',
21+
headers: {
22+
Authorization: `Bearer ${webhookSecret}`,
23+
'Content-Type': 'application/json',
24+
},
25+
body: JSON.stringify({ type: 'scheduled' }),
26+
});
27+
28+
const data = await response.json();
29+
30+
if (data.success && data.data?.sent) {
31+
console.log(`Discord scheduled update sent: status=${data.data.status}, score=${data.data.score}`);
32+
} else if (data.success) {
33+
console.log('Discord update skipped (no change or not configured)');
34+
} else {
35+
console.error('Discord update failed:', data.error);
36+
}
37+
38+
return new Response(JSON.stringify(data), {
39+
status: response.status,
40+
headers: { 'Content-Type': 'application/json' },
41+
});
42+
} catch (error) {
43+
console.error('Failed to send Discord update:', error);
44+
return new Response(
45+
JSON.stringify({ error: 'Failed to send Discord update' }),
46+
{ status: 500 }
47+
);
48+
}
49+
}
50+
51+
// Run every 6 hours (0:00, 6:00, 12:00, 18:00 UTC)
52+
export const config: Config = {
53+
schedule: '0 */6 * * *',
54+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Config } from '@netlify/functions';
2+
3+
/**
4+
* Scheduled function to check network status and alert on changes
5+
* Runs every 5 minutes to detect status changes quickly
6+
* Only sends alerts when status actually changes (with cooldown)
7+
*/
8+
export default async function handler() {
9+
const siteUrl = process.env.URL || process.env.DEPLOY_PRIME_URL;
10+
const webhookSecret = process.env.DISCORD_WEBHOOK_SECRET;
11+
12+
if (!siteUrl || !webhookSecret) {
13+
// Silent return - don't spam logs if not configured
14+
return new Response('Not configured', { status: 200 });
15+
}
16+
17+
try {
18+
const response = await fetch(`${siteUrl}/api/discord-webhook`, {
19+
method: 'POST',
20+
headers: {
21+
Authorization: `Bearer ${webhookSecret}`,
22+
'Content-Type': 'application/json',
23+
},
24+
body: JSON.stringify({ type: 'status_check' }),
25+
});
26+
27+
const data = await response.json();
28+
29+
// Only log if an alert was actually sent
30+
if (data.success && data.data?.sent) {
31+
console.log(
32+
`Discord status alert sent: type=${data.data.type}, status=${data.data.status}, score=${data.data.score}`
33+
);
34+
}
35+
36+
return new Response(JSON.stringify(data), {
37+
status: response.status,
38+
headers: { 'Content-Type': 'application/json' },
39+
});
40+
} catch (error) {
41+
console.error('Discord status check failed:', error);
42+
return new Response(
43+
JSON.stringify({ error: 'Status check failed' }),
44+
{ status: 500 }
45+
);
46+
}
47+
}
48+
49+
// Run every 5 minutes
50+
export const config: Config = {
51+
schedule: '*/5 * * * *',
52+
};
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { NextResponse } from 'next/server';
2+
import { getNetworkStatusState, updateNetworkStatusState } from '@/lib/db';
3+
import {
4+
buildHealthSummaryEmbed,
5+
buildStatusChangeEmbed,
6+
buildWebhookPayload,
7+
sendDiscordWebhook,
8+
canSendAlert,
9+
shouldAlert,
10+
} from '@/lib/discord';
11+
import { checkRateLimit, getClientIp, addRateLimitHeaders } from '@/lib/rate-limit';
12+
import type { ApiResponse, HealthStatus, DiscordNotificationType, NetworkHealth } from '@/lib/types';
13+
14+
// Environment variables
15+
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
16+
const DISCORD_WEBHOOK_SECRET = process.env.DISCORD_WEBHOOK_SECRET;
17+
18+
// Rate limit: 5 requests per minute (Discord webhook rate limit is 30/min)
19+
const RATE_LIMIT_CONFIG = { limit: 5, windowSeconds: 60 };
20+
21+
interface WebhookRequestBody {
22+
type?: 'scheduled' | 'status_check';
23+
force?: boolean;
24+
}
25+
26+
interface WebhookResponseData {
27+
sent: boolean;
28+
type: DiscordNotificationType;
29+
status: HealthStatus;
30+
score: number;
31+
}
32+
33+
/**
34+
* Fetch network health from the internal API
35+
* This reuses the existing health endpoint logic
36+
*/
37+
async function fetchNetworkHealth(baseUrl: string): Promise<NetworkHealth | null> {
38+
try {
39+
const response = await fetch(`${baseUrl}/api/health`, {
40+
next: { revalidate: 0 }, // Don't cache
41+
});
42+
43+
if (!response.ok) return null;
44+
45+
const data = await response.json();
46+
if (!data.success || !data.data) return null;
47+
48+
return data.data as NetworkHealth;
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
export async function POST(request: Request) {
55+
// Verify webhook URL is configured
56+
if (!DISCORD_WEBHOOK_URL) {
57+
console.error('DISCORD_WEBHOOK_URL not configured');
58+
return NextResponse.json<ApiResponse<never>>(
59+
{ success: false, error: 'Discord webhook not configured' },
60+
{ status: 500 }
61+
);
62+
}
63+
64+
// Verify authorization
65+
const authHeader = request.headers.get('authorization');
66+
if (!DISCORD_WEBHOOK_SECRET) {
67+
console.error('DISCORD_WEBHOOK_SECRET not configured');
68+
return NextResponse.json<ApiResponse<never>>(
69+
{ success: false, error: 'Webhook secret not configured' },
70+
{ status: 500 }
71+
);
72+
}
73+
74+
if (authHeader !== `Bearer ${DISCORD_WEBHOOK_SECRET}`) {
75+
return NextResponse.json<ApiResponse<never>>(
76+
{ success: false, error: 'Unauthorized' },
77+
{ status: 401 }
78+
);
79+
}
80+
81+
// Rate limiting
82+
const clientIp = getClientIp(request);
83+
const rateLimitResult = checkRateLimit(`discord-webhook:${clientIp}`, RATE_LIMIT_CONFIG);
84+
85+
if (!rateLimitResult.success) {
86+
const response = NextResponse.json<ApiResponse<never>>(
87+
{ success: false, error: 'Rate limit exceeded' },
88+
{ status: 429 }
89+
);
90+
return addRateLimitHeaders(response, rateLimitResult);
91+
}
92+
93+
try {
94+
// Parse request body
95+
let body: WebhookRequestBody = { type: 'scheduled' };
96+
try {
97+
body = await request.json();
98+
} catch {
99+
// Default to scheduled if no body
100+
}
101+
102+
// Determine base URL for internal API calls
103+
const baseUrl =
104+
process.env.URL ||
105+
process.env.DEPLOY_URL ||
106+
`${request.headers.get('x-forwarded-proto') || 'https'}://${request.headers.get('host')}`;
107+
108+
// Fetch current network health
109+
const currentHealth = await fetchNetworkHealth(baseUrl);
110+
111+
if (!currentHealth) {
112+
return NextResponse.json<ApiResponse<never>>(
113+
{ success: false, error: 'Failed to fetch network health' },
114+
{ status: 500 }
115+
);
116+
}
117+
118+
// Get previous state
119+
const previousState = await getNetworkStatusState();
120+
121+
let shouldSendWebhook = false;
122+
let notificationType: DiscordNotificationType = 'scheduled';
123+
let embed;
124+
125+
if (body.type === 'scheduled' || body.force) {
126+
// Scheduled update - always send summary
127+
embed = buildHealthSummaryEmbed(currentHealth);
128+
shouldSendWebhook = true;
129+
} else {
130+
// Status check - only alert on changes
131+
if (previousState) {
132+
const alertCheck = shouldAlert(
133+
previousState.status,
134+
previousState.network_score,
135+
previousState.active_nodes,
136+
currentHealth
137+
);
138+
139+
// Check cooldown
140+
if (alertCheck.shouldAlert && canSendAlert(previousState.last_alert_sent)) {
141+
shouldSendWebhook = true;
142+
notificationType = alertCheck.type;
143+
embed = buildStatusChangeEmbed(previousState.status, currentHealth, notificationType);
144+
}
145+
} else {
146+
// No previous state - this is the first run, just update state without alerting
147+
shouldSendWebhook = false;
148+
}
149+
}
150+
151+
// Send to Discord if needed
152+
let webhookResult: { success: boolean; error?: string } = { success: true };
153+
if (shouldSendWebhook && embed) {
154+
// Mention @everyone only for offline status
155+
const mentionEveryone = currentHealth.status === 'offline';
156+
const payload = buildWebhookPayload(embed, mentionEveryone);
157+
webhookResult = await sendDiscordWebhook(DISCORD_WEBHOOK_URL, payload);
158+
}
159+
160+
// Update state in database
161+
await updateNetworkStatusState(
162+
currentHealth.status,
163+
currentHealth.network_score ?? 0,
164+
currentHealth.active_nodes,
165+
shouldSendWebhook && webhookResult.success
166+
);
167+
168+
const response = NextResponse.json<ApiResponse<WebhookResponseData>>({
169+
success: webhookResult.success,
170+
data: {
171+
sent: shouldSendWebhook && webhookResult.success,
172+
type: notificationType,
173+
status: currentHealth.status,
174+
score: currentHealth.network_score ?? 0,
175+
},
176+
...(webhookResult.error && { error: webhookResult.error }),
177+
});
178+
179+
return addRateLimitHeaders(response, rateLimitResult);
180+
} catch (error) {
181+
console.error('Discord webhook error:', error);
182+
return NextResponse.json<ApiResponse<never>>(
183+
{
184+
success: false,
185+
error: error instanceof Error ? error.message : 'Unknown error',
186+
},
187+
{ status: 500 }
188+
);
189+
}
190+
}
191+
192+
// GET endpoint for health check
193+
export async function GET() {
194+
const configured = Boolean(DISCORD_WEBHOOK_URL && DISCORD_WEBHOOK_SECRET);
195+
196+
return NextResponse.json<ApiResponse<{ configured: boolean }>>({
197+
success: true,
198+
data: { configured },
199+
});
200+
}

src/app/api/health/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { NextResponse } from 'next/server';
22
import { getNetworkHealth, db } from '@/lib/db';
33
import type { ApiResponse, NetworkHealth } from '@/lib/types';
44

5-
export const revalidate = 30; // Cache for 30 seconds
5+
// Force dynamic rendering to prevent stale cache issues on Netlify
6+
export const dynamic = 'force-dynamic';
7+
export const revalidate = 0;
68

79
// meshcore-bot API URL (configured via environment variable)
810
const BOT_API_URL = process.env.BOT_API_URL;
@@ -329,7 +331,11 @@ export async function GET() {
329331
data: health,
330332
});
331333

332-
response.headers.set('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=15');
334+
// Disable CDN caching for real-time data (Cloudflare + Netlify)
335+
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
336+
response.headers.set('CDN-Cache-Control', 'no-store');
337+
response.headers.set('Netlify-CDN-Cache-Control', 'no-store, durable=false');
338+
response.headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
333339

334340
return response;
335341
} catch (error) {

src/app/api/nodes/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { NextResponse } from 'next/server';
22
import { getNodesWithStats } from '@/lib/db';
33
import type { ApiResponse, NodeWithStats } from '@/lib/types';
44

5+
// Force dynamic rendering to prevent stale cache issues on Netlify
6+
export const dynamic = 'force-dynamic';
7+
export const revalidate = 0;
8+
59
/**
610
* GET /api/nodes
711
* Returns all nodes in the network with their computed statistics
@@ -10,10 +14,18 @@ export async function GET() {
1014
try {
1115
const nodes = await getNodesWithStats();
1216

13-
return NextResponse.json<ApiResponse<NodeWithStats[]>>({
17+
const response = NextResponse.json<ApiResponse<NodeWithStats[]>>({
1418
success: true,
1519
data: nodes,
1620
});
21+
22+
// Disable CDN caching for real-time data (Cloudflare + Netlify)
23+
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
24+
response.headers.set('CDN-Cache-Control', 'no-store');
25+
response.headers.set('Netlify-CDN-Cache-Control', 'no-store, durable=false');
26+
response.headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
27+
28+
return response;
1729
} catch (error) {
1830
console.error('Error fetching nodes:', error);
1931

0 commit comments

Comments
 (0)